@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
package/dist/init.js
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ia init`: docs/specs/consumer-cli-contract/README.md §2.1, implementing
|
|
3
|
+
* docs/specs/workspace-initialization-apply/README.md (M5.2) to reach the end state
|
|
4
|
+
* docs/specs/workspace-initialization/README.md (M5.1) fixes.
|
|
5
|
+
*
|
|
6
|
+
* Without `--apply` it prints the reviewable plan: the five owned paths, the target's classification (M5.2 §4.1),
|
|
7
|
+
* the conflicts detected now, the starter it would write, the bundled base pin and the four steps. It exits 0
|
|
8
|
+
* whether or not conflicts were found, because reporting them is the command's job.
|
|
9
|
+
*
|
|
10
|
+
* With `--apply` it runs M5.2 §3.1 in order — verify the pin, install the bundled base unless it is already
|
|
11
|
+
* installed as planned, write `system.ia`, write `records/workspace.ia`, admit and read the `@distribution`
|
|
12
|
+
* identity from the compiled graph, write `.ia/release.json` last — through the shared install services and
|
|
13
|
+
* `createFile`. No journal is written: a rerun derives from the workspace which steps are done (§4.2). No step
|
|
14
|
+
* performs a network request. `<directory>` is the only way to name the target: this verb never performs root
|
|
15
|
+
* discovery, and `--root` is refused by the grammar rather than silently ignored.
|
|
16
|
+
*
|
|
17
|
+
* `--host claude` or `--host codex` (docs/specs/host-registration/README.md §4 "init --host") adds one
|
|
18
|
+
* step after the four: once the workspace is initialized, the `ia host <host> --apply` path runs under the question
|
|
19
|
+
* already answered here, so it never asks a second one. The plan names that step and writes nothing for it — the
|
|
20
|
+
* host plan needs `.ia/release.json`, which only the apply writes. A host step that refuses leaves the workspace
|
|
21
|
+
* initialized: the refusal keeps the service's code, message and location, and its next action names the rerun.
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync, } from 'node:fs';
|
|
25
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
26
|
+
import { DISTRIBUTION_ENGINE_VERSION, INSTALL_PATHS } from '@inventarch/db/distribution';
|
|
27
|
+
import { verifyArchive } from '@inventarch/distribution/archive';
|
|
28
|
+
import { assertHomeOutsideWorkspace } from '@inventarch/distribution/host-home';
|
|
29
|
+
import { applyInstallation, cacheArchive, planInstallation } from '@inventarch/distribution/install';
|
|
30
|
+
import { resolveReleases } from '@inventarch/distribution/resolve';
|
|
31
|
+
import { createFile, openWorkspaceSession, readInstalledState, resolveCatalog, } from '@inventarch/distribution/services';
|
|
32
|
+
import { parseArguments, UsageError } from './args.js';
|
|
33
|
+
import { findCommand } from './commands.js';
|
|
34
|
+
import { confirm, iaHomeOf, Interrupted, Refusal, refusalOf } from './consumer.js';
|
|
35
|
+
import { clearDecline, runDecline } from './decline.js';
|
|
36
|
+
import { CONFIRMATION } from './distribute.js';
|
|
37
|
+
import { applyHostSet, collectHost, hostNotes, rootedNext } from './host.js';
|
|
38
|
+
import { located } from './home-remedy.js';
|
|
39
|
+
import { codeOf } from './session.js';
|
|
40
|
+
import { atom, document, entry, fieldRows, headerLine, quote, sectionLabel, truncateDigest, words } from './render.js';
|
|
41
|
+
/**
|
|
42
|
+
* Relative to the `@inventarch/cli` package root, as `assets/vocabulary.json` is (vocabulary.ts CATALOGUE_PATH). The base
|
|
43
|
+
* package's id is read from this pin and appears nowhere in this source: M5.1 §3.2 makes the pin the only authority.
|
|
44
|
+
*/
|
|
45
|
+
export const PIN_PATH = 'assets/base.json';
|
|
46
|
+
export const baseArchivePath = (digest) => `assets/base/${digest}.ia.tgz`;
|
|
47
|
+
export const DESCRIPTOR_PATH = '.ia/release.json';
|
|
48
|
+
export const STARTER_VERSION = '0.1.0';
|
|
49
|
+
/** The installer's lock, `acquire()` at apps/distribution/src/install.ts:70; `INSTALL_PATHS` does not name it. */
|
|
50
|
+
export const INSTALL_LOCK = '.ia/distributions/install-lock.json';
|
|
51
|
+
const cachePath = (digest) => `.ia/distributions/cache/${digest}.ia.tgz`;
|
|
52
|
+
const systemFolder = (name) => `.ia/src/systems/${name}`;
|
|
53
|
+
const systemPath = (name) => `${systemFolder(name)}/system.ia`;
|
|
54
|
+
const recordsPath = (name) => `${systemFolder(name)}/records/workspace.ia`;
|
|
55
|
+
/** `packages/db/src/distribution/codec.ts` packageId and identifier: the id rule and the system-name ceiling. */
|
|
56
|
+
export const PACKAGE_ID = /^[a-z][a-z0-9.-]*\/[a-z][a-z0-9-]*$/;
|
|
57
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
58
|
+
const NAME_LIMIT = 64;
|
|
59
|
+
/** M5.2 §4.3: `createFile`'s temporary name, `<target>.<randomUUID()>.tmp` (apps/distribution/src/files.ts:76). */
|
|
60
|
+
export const LEFTOVER = /^(system\.ia|workspace\.ia|release\.json)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/;
|
|
61
|
+
const DAMAGED = 'The ia installation is damaged; reinstall @inventarch/cli.';
|
|
62
|
+
/** M5.1 §2.1: the three M4 paths plus the starter system folder and the release descriptor. */
|
|
63
|
+
export function ownedPaths(name) {
|
|
64
|
+
return [
|
|
65
|
+
{ path: '.ia/src/', purpose: 'authored records' },
|
|
66
|
+
{ path: `${systemFolder(name ?? '<name>')}/`, purpose: 'the starter system, written once' },
|
|
67
|
+
{ path: DESCRIPTOR_PATH, purpose: 'the release descriptor, written once' },
|
|
68
|
+
{ path: INSTALL_PATHS.lock, purpose: 'direct requests and resolved versions' },
|
|
69
|
+
{ path: '.ia/distributions/', purpose: 'install state, store and generations' },
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
/** §2.1: an existing directory, or a single new segment under an existing directory. Anything else is usage. */
|
|
73
|
+
export function resolveTarget(cwd, supplied) {
|
|
74
|
+
const target = supplied === undefined ? resolve(cwd) : isAbsolute(supplied) ? resolve(supplied) : resolve(cwd, supplied);
|
|
75
|
+
const here = statSync(target, { throwIfNoEntry: false });
|
|
76
|
+
if (here !== undefined && !here.isDirectory())
|
|
77
|
+
throw new Refusal('IA-CLI-USAGE', `${target} is not a directory`, 2, { path: target }, 'Name an existing directory, or one new directory inside an existing one.');
|
|
78
|
+
if (here === undefined) {
|
|
79
|
+
const parent = statSync(dirname(target), { throwIfNoEntry: false });
|
|
80
|
+
if (parent === undefined || !parent.isDirectory())
|
|
81
|
+
throw new Refusal('IA-CLI-USAGE', `${dirname(target)} is not an existing directory`, 2, { path: target }, 'Create the parent directory first, or name one that exists.');
|
|
82
|
+
}
|
|
83
|
+
// The directory the user chose may be reached through links (macOS /tmp, a linked checkout); it is initialized at
|
|
84
|
+
// its real path, as every other verb opens a supplied --root (`requireRoot`). Links below it stay refused.
|
|
85
|
+
return here === undefined ? join(realpathSync(dirname(target)), basename(target)) : realpathSync(target);
|
|
86
|
+
}
|
|
87
|
+
const writable = (path) => {
|
|
88
|
+
try {
|
|
89
|
+
accessSync(path, constants.W_OK);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* M5.1 §3.2 and §5: read the pin and verify the bytes against it, never falling back to another file. A missing
|
|
98
|
+
* archive is read as no bytes, so the archive verifier's own refusal reports it; a pin that cannot be read at all
|
|
99
|
+
* has no digest to verify against and is the CLI's own failure. Either way the next action says the installation is
|
|
100
|
+
* damaged, because nothing the user did to the target can cause it.
|
|
101
|
+
*/
|
|
102
|
+
export function readBase(packageRoot) {
|
|
103
|
+
const pinPath = resolve(packageRoot, PIN_PATH);
|
|
104
|
+
let pin;
|
|
105
|
+
try {
|
|
106
|
+
const value = JSON.parse(readFileSync(pinPath, 'utf8'));
|
|
107
|
+
const keys = Object.keys(value).sort().join(',');
|
|
108
|
+
if (keys !== 'archive,id,manifest,version' ||
|
|
109
|
+
!Object.values(value).every((item) => typeof item === 'string') ||
|
|
110
|
+
!PACKAGE_ID.test(String(value['id'])))
|
|
111
|
+
throw new Error('unexpected fields');
|
|
112
|
+
if (!/^[a-f0-9]{64}$/.test(String(value['archive'])) || !/^[a-f0-9]{64}$/.test(String(value['manifest'])))
|
|
113
|
+
throw new Error('malformed digest');
|
|
114
|
+
pin = value;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
throw new Refusal('IA-CLI-FAILED', `The bundled base package pin cannot be read: ${error instanceof Error ? error.message : String(error)}`, 3, { path: pinPath }, DAMAGED);
|
|
118
|
+
}
|
|
119
|
+
const archivePath = resolve(packageRoot, baseArchivePath(pin.archive));
|
|
120
|
+
const bytes = existsSync(archivePath) ? readFileSync(archivePath) : Buffer.alloc(0);
|
|
121
|
+
try {
|
|
122
|
+
const verified = verifyArchive(bytes, pin.archive);
|
|
123
|
+
if (verified.manifestDigest !== pin.manifest ||
|
|
124
|
+
verified.manifest.id !== pin.id ||
|
|
125
|
+
verified.manifest.version !== pin.version)
|
|
126
|
+
throw new Refusal('IA-CLI-FAILED', 'The bundled base archive does not carry the pinned manifest', 3, { path: archivePath }, DAMAGED);
|
|
127
|
+
return { pin, bytes, systems: verified.manifest.systems.map((system) => system.name) };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof Refusal)
|
|
131
|
+
throw error;
|
|
132
|
+
throw new Refusal(codeOf(error, 'IA-CLI-FAILED'), error instanceof Error ? error.message : String(error), 3, { path: archivePath }, DAMAGED);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** M5.1 §2.3's normalization: lowercase, every run outside `[a-z0-9-]` becomes `-`, no leading or trailing `-`. */
|
|
136
|
+
export const normalizeName = (directory) => directory
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
139
|
+
.replace(/^-+|-+$/g, '');
|
|
140
|
+
/** M5.1 §2.3: valid only if the name, the full id and the public-system rule all hold. Nothing is invented. */
|
|
141
|
+
export function starterIdentity(root, supplied, systems) {
|
|
142
|
+
const name = supplied === undefined ? normalizeName(basename(root)) : supplied.slice(supplied.indexOf('/') + 1);
|
|
143
|
+
const id = supplied ?? `local/${name}`;
|
|
144
|
+
const ask = supplied === undefined ? 'pass --id <provider/name>' : 'pass another --id';
|
|
145
|
+
const refused = (reason) => ({ name: null, id: null, conflict: { path: root, reason } });
|
|
146
|
+
if (!NAME.test(name) || !PACKAGE_ID.test(id) || id.includes('..'))
|
|
147
|
+
return refused(`The directory name "${basename(root)}" does not normalize to a package name; ${ask}`);
|
|
148
|
+
if (name.length > NAME_LIMIT)
|
|
149
|
+
return refused(`The name "${name}" is longer than a system name may be; ${ask}`);
|
|
150
|
+
if (systems.includes(name))
|
|
151
|
+
return refused(`"${name}" is the name of a public system the base package installs; ${ask}`);
|
|
152
|
+
return { name, id, conflict: null };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* M5.1 §2.2, verbatim in shape: the steward needs `agent-system`, and `governance` is required. `work-system` is
|
|
156
|
+
* required so a fresh workspace can author `@plan`, `@milestone`, `@task` and `@decision` records with no edit: a
|
|
157
|
+
* system may use only words owned by systems it directly requires (operator decision, 2026-09-25).
|
|
158
|
+
*/
|
|
159
|
+
export function starterSystem(name) {
|
|
160
|
+
return [
|
|
161
|
+
'#! ia 1.0',
|
|
162
|
+
'',
|
|
163
|
+
`@system ${name}`,
|
|
164
|
+
' provider "local"',
|
|
165
|
+
` version "${STARTER_VERSION}"`,
|
|
166
|
+
` describes "The authored records of the ${name} workspace."`,
|
|
167
|
+
` steward @agent ${name}-steward`,
|
|
168
|
+
' requires',
|
|
169
|
+
' - agent-system',
|
|
170
|
+
' - work-system',
|
|
171
|
+
' - workspace-system',
|
|
172
|
+
'',
|
|
173
|
+
`@agent ${name}-steward`,
|
|
174
|
+
' meaning',
|
|
175
|
+
` says "Owns the ${name} system and its records."`,
|
|
176
|
+
' answers "Who owns this system?"',
|
|
177
|
+
' governance',
|
|
178
|
+
' applies []',
|
|
179
|
+
'',
|
|
180
|
+
].join('\n');
|
|
181
|
+
}
|
|
182
|
+
/** M5.1 §2.2: the workspace composes every installed system, so nothing installed is unreachable. */
|
|
183
|
+
export function starterRecords(name, systems) {
|
|
184
|
+
return [
|
|
185
|
+
'#! ia 1.0',
|
|
186
|
+
'',
|
|
187
|
+
`@workspace ${name}`,
|
|
188
|
+
' meaning',
|
|
189
|
+
` says "The ${name} workspace and the public systems it composes."`,
|
|
190
|
+
' answers "Which systems does this workspace compose?"',
|
|
191
|
+
' composition',
|
|
192
|
+
` systems [${systems.map((system) => `@system ${system}`).join(', ')}]`,
|
|
193
|
+
'',
|
|
194
|
+
`@distribution ${name}-distribution`,
|
|
195
|
+
' meaning',
|
|
196
|
+
` says "The release root of the ${name} workspace."`,
|
|
197
|
+
' answers "Which records does this release ship?"',
|
|
198
|
+
' distribution',
|
|
199
|
+
` records [@workspace ${name}]`,
|
|
200
|
+
'',
|
|
201
|
+
].join('\n');
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Git as a child process, with the host's environment rather than this process's, so the probe is a function of the
|
|
205
|
+
* same explicit host every verb receives. `core.fsmonitor` is disabled because it is the configured command a
|
|
206
|
+
* read-only query could otherwise start. A missing executable is `null`, which is not the same fact as "no checkout".
|
|
207
|
+
*/
|
|
208
|
+
export const gitIn = (cwd, env) => (args) => {
|
|
209
|
+
const result = spawnSync('git', ['-c', 'core.fsmonitor=false', ...args], {
|
|
210
|
+
cwd,
|
|
211
|
+
env: Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== undefined)),
|
|
212
|
+
encoding: 'utf8',
|
|
213
|
+
timeout: 10_000,
|
|
214
|
+
windowsHide: true,
|
|
215
|
+
});
|
|
216
|
+
return gitAnswer(result);
|
|
217
|
+
};
|
|
218
|
+
/** `packages/db/src/distribution/contracts.ts` url(): canonical, credential-free HTTPS without query or fragment. */
|
|
219
|
+
const canonicalHttps = (value) => {
|
|
220
|
+
try {
|
|
221
|
+
const url = new URL(value);
|
|
222
|
+
return (url.protocol === 'https:' &&
|
|
223
|
+
url.username === '' &&
|
|
224
|
+
url.password === '' &&
|
|
225
|
+
url.search === '' &&
|
|
226
|
+
url.hash === '' &&
|
|
227
|
+
url.href === value);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
const LOCAL = { repository: null, commit: null, recipe: 'ustar-v1', epoch: 0 };
|
|
234
|
+
/**
|
|
235
|
+
* M5.1 §2.3. Sourced only from a Git checkout with a commit and an HTTPS remote: `origin` when it exists, else the
|
|
236
|
+
* first remote Git lists. The remote's configured URL is read as written, so an SSH remote is "no HTTPS remote" and
|
|
237
|
+
* is never rewritten into a guessed HTTPS one; a URL carrying credentials is refused the same way rather than copied
|
|
238
|
+
* into a file meant to be committed.
|
|
239
|
+
*/
|
|
240
|
+
export function readProvenance(git) {
|
|
241
|
+
const local = (missing) => ({ source: LOCAL, missing });
|
|
242
|
+
const inside = git(['rev-parse', '--is-inside-work-tree']);
|
|
243
|
+
if (inside === null)
|
|
244
|
+
return local('git');
|
|
245
|
+
if (inside.status !== 0 || inside.stdout.trim() !== 'true')
|
|
246
|
+
return local('checkout');
|
|
247
|
+
const head = git(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']);
|
|
248
|
+
const commit = head?.stdout.trim() ?? '';
|
|
249
|
+
if (head?.status !== 0 || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit))
|
|
250
|
+
return local('commit');
|
|
251
|
+
const remotes = (git(['remote'])?.stdout ?? '')
|
|
252
|
+
.split(/\r?\n/)
|
|
253
|
+
.map((line) => line.trim())
|
|
254
|
+
.filter(Boolean);
|
|
255
|
+
const remote = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
256
|
+
if (remote === undefined)
|
|
257
|
+
return local('https-remote');
|
|
258
|
+
const repository = git(['config', '--get', `remote.${remote}.url`])?.stdout.trim() ?? '';
|
|
259
|
+
if (!canonicalHttps(repository))
|
|
260
|
+
return local('https-remote');
|
|
261
|
+
const epoch = Number(git(['log', '-1', '--format=%ct', commit])?.stdout.trim());
|
|
262
|
+
if (!Number.isSafeInteger(epoch) || epoch < 0 || epoch > 8_589_934_591)
|
|
263
|
+
return local('commit');
|
|
264
|
+
return { source: { repository, commit, recipe: 'ustar-v1', epoch }, missing: null };
|
|
265
|
+
}
|
|
266
|
+
const MISSING = {
|
|
267
|
+
git: 'Git is not available to this process',
|
|
268
|
+
checkout: 'the target is not a Git checkout',
|
|
269
|
+
commit: 'the checkout has no commit',
|
|
270
|
+
'https-remote': 'the checkout has no HTTPS remote',
|
|
271
|
+
};
|
|
272
|
+
/** M5.1 §2.3's table. `distribution` stays null until the compiled record is read (§3.1 step 6). */
|
|
273
|
+
export function starterDescriptor(id, name, base, provenance, distribution = null) {
|
|
274
|
+
return {
|
|
275
|
+
formatVersion: 1,
|
|
276
|
+
id,
|
|
277
|
+
version: STARTER_VERSION,
|
|
278
|
+
distribution,
|
|
279
|
+
engine: `^${DISTRIBUTION_ENGINE_VERSION}`,
|
|
280
|
+
language: ['1.0'],
|
|
281
|
+
dependencies: [{ id: base.pin.id, range: `^${base.pin.version}`, systems: base.systems }],
|
|
282
|
+
assets: [],
|
|
283
|
+
license: 'UNLICENSED',
|
|
284
|
+
description: `The ${name} workspace.`,
|
|
285
|
+
source: provenance.source,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** Two-space JSON with a final newline; `decodeReleaseDescriptor` requires the dependency rows sorted, as they are. */
|
|
289
|
+
export const descriptorText = (descriptor) => JSON.stringify(descriptor, null, 2) + '\n';
|
|
290
|
+
/** M5.2 §4.1: exactly the one request and the one package the pin describes. */
|
|
291
|
+
export const locksBase = (lock, pin) => lock !== undefined &&
|
|
292
|
+
lock.requests.length === 1 &&
|
|
293
|
+
lock.requests[0].id === pin.id &&
|
|
294
|
+
lock.requests[0].range === `^${pin.version}` &&
|
|
295
|
+
lock.packages.length === 1 &&
|
|
296
|
+
lock.packages[0].id === pin.id &&
|
|
297
|
+
lock.packages[0].version === pin.version &&
|
|
298
|
+
lock.packages[0].archive === pin.archive &&
|
|
299
|
+
lock.packages[0].manifest === pin.manifest;
|
|
300
|
+
/** Every regular file, link or other entry below `path`, root-relative with `/`, in sorted order. */
|
|
301
|
+
function filesBelow(root, path) {
|
|
302
|
+
const absolute = resolve(root, path);
|
|
303
|
+
if (!existsSync(absolute))
|
|
304
|
+
return [];
|
|
305
|
+
const found = [];
|
|
306
|
+
const walk = (relative) => {
|
|
307
|
+
for (const name of readdirSync(resolve(root, relative)).sort()) {
|
|
308
|
+
const child = `${relative}/${name}`;
|
|
309
|
+
const stat = lstatSync(resolve(root, child));
|
|
310
|
+
if (stat.isDirectory() && !stat.isSymbolicLink())
|
|
311
|
+
walk(child);
|
|
312
|
+
else
|
|
313
|
+
found.push(child);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
walk(path);
|
|
317
|
+
return found;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* M5.2 §4.1. The starter bytes compared are what this invocation would write for its resolved id: they depend only
|
|
321
|
+
* on the name and the bundled manifest, so they are recomputed on every run instead of being journaled.
|
|
322
|
+
*/
|
|
323
|
+
export function collectInit(request) {
|
|
324
|
+
const { root, host } = request;
|
|
325
|
+
const base = readBase(request.packageRoot);
|
|
326
|
+
const conflicts = [];
|
|
327
|
+
const present = existsSync(root);
|
|
328
|
+
const entries = present ? readdirSync(root).length : 0;
|
|
329
|
+
const identity = starterIdentity(root, request.id, base.systems);
|
|
330
|
+
if (identity.conflict !== null)
|
|
331
|
+
conflicts.push(identity.conflict);
|
|
332
|
+
const { name, id } = identity;
|
|
333
|
+
const provenance = readProvenance(request.git);
|
|
334
|
+
const files = name === null
|
|
335
|
+
? []
|
|
336
|
+
: [
|
|
337
|
+
{ path: systemPath(name), text: starterSystem(name) },
|
|
338
|
+
{ path: recordsPath(name), text: starterRecords(name, base.systems) },
|
|
339
|
+
];
|
|
340
|
+
const leftovers = [];
|
|
341
|
+
const written = new Set();
|
|
342
|
+
let lock = false, installed = false;
|
|
343
|
+
if (present) {
|
|
344
|
+
// readInstalledState refuses while `.ia/distributions/pending.json` exists, which is M5.2 §4.1's
|
|
345
|
+
// recovery-required: an interrupted apply must be recovered before anything reasons about this workspace.
|
|
346
|
+
// Its rows are held back so the plan lists authored source, then the lock, then install state, as M4 did.
|
|
347
|
+
const installation = [];
|
|
348
|
+
// M5.2 §4.1: a held or abandoned install lock is recovery-required too. A process killed inside
|
|
349
|
+
// applyInstallation skips the `finally` that releases it (apps/distribution/src/install.ts:67-97), before
|
|
350
|
+
// `pending` exists and after it is removed. `ia-distribution recover` clears a dead holder's lock and refuses a
|
|
351
|
+
// live one (`acquire(root, true)`, install.ts:71), so it is the remedy whether or not a journal exists; the
|
|
352
|
+
// journal case keeps the reader's own refusal below.
|
|
353
|
+
if (!existsSync(resolve(root, INSTALL_PATHS.pending)) && existsSync(resolve(root, INSTALL_LOCK)))
|
|
354
|
+
throw new Refusal('IA-CLI-RECOVERY-REQUIRED', `An installer holds or left the install lock ${INSTALL_LOCK}`, 3, { path: root }, `If no installer is running, run "ia-distribution recover --root ${root}" before initializing this directory.`);
|
|
355
|
+
try {
|
|
356
|
+
const state = readInstalledState({ root });
|
|
357
|
+
if (state.status === 'installed') {
|
|
358
|
+
if (locksBase(state.lock, base.pin))
|
|
359
|
+
installed = true;
|
|
360
|
+
else
|
|
361
|
+
installation.push({ path: INSTALL_PATHS.active, reason: 'A generation is already installed and active' });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
const code = codeOf(error, 'IA-CLI-FAILED');
|
|
366
|
+
if (code !== 'IA-DB-SOURCE-UNAVAILABLE')
|
|
367
|
+
throw error;
|
|
368
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
369
|
+
if (existsSync(resolve(root, INSTALL_PATHS.pending)))
|
|
370
|
+
throw new Refusal(code, message, 3, { path: root }, `Run "ia-distribution recover --root ${root}" before initializing this directory.`);
|
|
371
|
+
// Any other unreadable installation state is a conflict the plan reports, with the reader's own words.
|
|
372
|
+
installation.push({ path: '.ia/distributions/', reason: message });
|
|
373
|
+
}
|
|
374
|
+
const leftoverDirectories = new Set([
|
|
375
|
+
'.ia',
|
|
376
|
+
...(name === null ? [] : [systemFolder(name), `${systemFolder(name)}/records`]),
|
|
377
|
+
]);
|
|
378
|
+
const leftover = (path) => leftoverDirectories.has(dirname(path)) && LEFTOVER.test(basename(path));
|
|
379
|
+
if (existsSync(resolve(root, '.ia')))
|
|
380
|
+
for (const entry of readdirSync(resolve(root, '.ia')).sort())
|
|
381
|
+
if (leftover(`.ia/${entry}`))
|
|
382
|
+
leftovers.push(`.ia/${entry}`);
|
|
383
|
+
const foreign = [];
|
|
384
|
+
for (const path of filesBelow(root, '.ia/src')) {
|
|
385
|
+
const planned = files.find((file) => file.path === path);
|
|
386
|
+
if (planned !== undefined) {
|
|
387
|
+
const stat = lstatSync(resolve(root, path));
|
|
388
|
+
if (stat.isFile() && readFileSync(resolve(root, path)).equals(Buffer.from(planned.text)))
|
|
389
|
+
written.add(path);
|
|
390
|
+
else
|
|
391
|
+
conflicts.push({ path, reason: 'Differs from the starter this initialization writes' });
|
|
392
|
+
}
|
|
393
|
+
else if (leftover(path))
|
|
394
|
+
leftovers.push(path);
|
|
395
|
+
else if (name !== null && path.startsWith(`${systemFolder(name)}/`))
|
|
396
|
+
conflicts.push({ path, reason: 'A file this initialization does not write' });
|
|
397
|
+
else
|
|
398
|
+
foreign.push(path);
|
|
399
|
+
}
|
|
400
|
+
if (foreign.length > 0)
|
|
401
|
+
conflicts.push({
|
|
402
|
+
path: '.ia/src',
|
|
403
|
+
reason: `The target already holds authored records this initialization did not write, starting with ${foreign[0]}`,
|
|
404
|
+
});
|
|
405
|
+
lock = existsSync(resolve(root, INSTALL_PATHS.lock));
|
|
406
|
+
if (lock && !installed)
|
|
407
|
+
conflicts.push({
|
|
408
|
+
path: INSTALL_PATHS.lock,
|
|
409
|
+
reason: 'A lock already names direct requests and resolved versions',
|
|
410
|
+
});
|
|
411
|
+
conflicts.push(...installation);
|
|
412
|
+
// M5.2 §4.1: a completed initialization is a conflict, because the starter now belongs to the user (band 100).
|
|
413
|
+
if (existsSync(resolve(root, DESCRIPTOR_PATH)))
|
|
414
|
+
conflicts.push({ path: DESCRIPTOR_PATH, reason: 'The target is already initialized' });
|
|
415
|
+
}
|
|
416
|
+
if (!writable(present ? root : dirname(root)))
|
|
417
|
+
conflicts.push({
|
|
418
|
+
path: present ? root : dirname(root),
|
|
419
|
+
reason: 'The directory that would be written is not writable by this process',
|
|
420
|
+
});
|
|
421
|
+
const untouched = !existsSync(resolve(root, '.ia/src')) && !lock && !installed && !existsSync(resolve(root, DESCRIPTOR_PATH));
|
|
422
|
+
const state = conflicts.length > 0 ? 'conflict' : untouched && leftovers.length === 0 ? 'fresh' : 'resumable';
|
|
423
|
+
const steps = [
|
|
424
|
+
{ id: 'install', status: installed ? 'done' : 'pending', path: INSTALL_PATHS.lock },
|
|
425
|
+
{
|
|
426
|
+
id: 'system',
|
|
427
|
+
status: name !== null && written.has(systemPath(name)) ? 'done' : 'pending',
|
|
428
|
+
path: name === null ? '' : systemPath(name),
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
id: 'records',
|
|
432
|
+
status: name !== null && written.has(recordsPath(name)) ? 'done' : 'pending',
|
|
433
|
+
path: name === null ? '' : recordsPath(name),
|
|
434
|
+
},
|
|
435
|
+
{ id: 'descriptor', status: 'pending', path: DESCRIPTOR_PATH },
|
|
436
|
+
];
|
|
437
|
+
return {
|
|
438
|
+
root,
|
|
439
|
+
owns: ownedPaths(name),
|
|
440
|
+
state,
|
|
441
|
+
conflicts,
|
|
442
|
+
leftovers: leftovers.sort(),
|
|
443
|
+
steps,
|
|
444
|
+
starter: {
|
|
445
|
+
name,
|
|
446
|
+
id,
|
|
447
|
+
files,
|
|
448
|
+
descriptor: id === null || name === null ? null : starterDescriptor(id, name, base, provenance),
|
|
449
|
+
base: base.pin,
|
|
450
|
+
systems: base.systems,
|
|
451
|
+
provenance,
|
|
452
|
+
},
|
|
453
|
+
host,
|
|
454
|
+
directory: present ? (entries === 0 ? 'empty' : 'populated') : 'absent',
|
|
455
|
+
entries,
|
|
456
|
+
invocation: request.invocation ?? 'ia init',
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* M5.2 §3.1 steps 2–7, in that order. Each step either completes or leaves a state `collectInit` classifies as
|
|
461
|
+
* resumable or recovery-required. The signal is observed at step boundaries (§5): before the first write it is the
|
|
462
|
+
* plain interruption and the target is byte-identical; after one, the interruption names the rerun that finishes.
|
|
463
|
+
*/
|
|
464
|
+
export async function applyInit(view, options) {
|
|
465
|
+
const { root, starter } = view;
|
|
466
|
+
if (view.state !== 'fresh' && view.state !== 'resumable')
|
|
467
|
+
throw conflictRefusal(view);
|
|
468
|
+
const { name, id } = starter;
|
|
469
|
+
if (name === null || id === null)
|
|
470
|
+
throw conflictRefusal(view);
|
|
471
|
+
const checkpoint = options.checkpoint ?? (() => { });
|
|
472
|
+
const resume = new Interrupted(`Run "${view.invocation} --apply --yes" again to finish initializing.`);
|
|
473
|
+
const boundary = (step) => {
|
|
474
|
+
checkpoint(`init:${step}`);
|
|
475
|
+
if (options.signal?.aborted)
|
|
476
|
+
throw resume;
|
|
477
|
+
};
|
|
478
|
+
// Step 2. The pin is read again rather than trusted from the plan, and must still be the one the plan showed.
|
|
479
|
+
const base = readBase(options.packageRoot);
|
|
480
|
+
if (base.pin.archive !== starter.base.archive || base.pin.manifest !== starter.base.manifest)
|
|
481
|
+
throw new Refusal('IA-CLI-FAILED', 'The bundled base package changed while planning', 3, { path: resolve(options.packageRoot, PIN_PATH) }, DAMAGED);
|
|
482
|
+
options.signal?.throwIfAborted();
|
|
483
|
+
// The last point at which the target is byte-identical to its state before the command (§5).
|
|
484
|
+
mkdirSync(root, { recursive: true });
|
|
485
|
+
for (const path of view.leftovers)
|
|
486
|
+
unlinkSync(resolve(root, path));
|
|
487
|
+
const step = (id) => view.steps.find((row) => row.id === id);
|
|
488
|
+
// Step 3: M5.1 §4's three calls, offline, resolved the way `ia install` resolves (distribute.ts collectPlan).
|
|
489
|
+
if (step('install').status === 'pending') {
|
|
490
|
+
try {
|
|
491
|
+
cacheArchive(root, base.bytes, base.pin.archive);
|
|
492
|
+
const choices = await resolveCatalog({
|
|
493
|
+
root,
|
|
494
|
+
entries: [{ path: cachePath(base.pin.archive), withdrawn: false }],
|
|
495
|
+
offline: true,
|
|
496
|
+
signal: options.signal,
|
|
497
|
+
});
|
|
498
|
+
options.signal?.throwIfAborted();
|
|
499
|
+
const lock = resolveReleases([{ id: base.pin.id, range: `^${base.pin.version}` }], choices, DISTRIBUTION_ENGINE_VERSION).lock;
|
|
500
|
+
applyInstallation(planInstallation(root, lock, 'install'), { checkpoint });
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (options.signal?.aborted)
|
|
504
|
+
throw resume;
|
|
505
|
+
// collectInit already refuses a lock present at planning time; this is another installer taking it between
|
|
506
|
+
// planning and apply. The installer's code is kept (§4.1 of the contract); the next action names the recovery
|
|
507
|
+
// that clears the lock if that installer has since died.
|
|
508
|
+
if (codeOf(error, '') === 'IA-DIST-INSTALL-BUSY')
|
|
509
|
+
throw new Refusal('IA-DIST-INSTALL-BUSY', error instanceof Error ? error.message : String(error), 3, { path: root }, `If no installer is running, an interrupted one left its lock: run "ia-distribution recover --root ${root}", then "${view.invocation} --apply --yes" again.`);
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
boundary('install');
|
|
514
|
+
// Steps 4 and 5: create-only, so an existing byte-equal file is skipped and nothing is ever rewritten.
|
|
515
|
+
for (const [id, text] of [
|
|
516
|
+
['system', starter.files[0].text],
|
|
517
|
+
['records', starter.files[1].text],
|
|
518
|
+
]) {
|
|
519
|
+
if (step(id).status === 'pending')
|
|
520
|
+
createFile(root, step(id).path, Buffer.from(text));
|
|
521
|
+
boundary(id);
|
|
522
|
+
}
|
|
523
|
+
// Step 6: the identity is read from the compiled graph, never string-constructed (M5.1 §2.3).
|
|
524
|
+
const session = openWorkspaceSession({ root });
|
|
525
|
+
let distribution;
|
|
526
|
+
try {
|
|
527
|
+
const errors = session.admission().findings.filter((finding) => finding.severity === 'error');
|
|
528
|
+
if (errors.length > 0)
|
|
529
|
+
throw new Refusal('IA-CLI-FAILED', `The initialized workspace failed admission with ${errors.length} error findings; first: ${errors[0].code}`, 3, { path: root }, `Run "ia validate --root ${root}" for the findings.`);
|
|
530
|
+
const record = session.reader
|
|
531
|
+
.records()
|
|
532
|
+
.find((node) => node.discriminator === 'distribution' &&
|
|
533
|
+
node.name === `${name}-distribution` &&
|
|
534
|
+
node.source.path === recordsPath(name));
|
|
535
|
+
if (record === undefined)
|
|
536
|
+
throw new Refusal('IA-CLI-FAILED', `The compiled workspace has no @distribution ${name}-distribution in ${recordsPath(name)}`, 3, { path: root });
|
|
537
|
+
distribution = record.identity;
|
|
538
|
+
}
|
|
539
|
+
finally {
|
|
540
|
+
session.close();
|
|
541
|
+
}
|
|
542
|
+
// Step 7, the completion point (§4.1).
|
|
543
|
+
createFile(root, DESCRIPTOR_PATH, Buffer.from(descriptorText(starterDescriptor(id, name, base, starter.provenance, distribution))));
|
|
544
|
+
checkpoint('init:descriptor');
|
|
545
|
+
const installed = readInstalledState({ root });
|
|
546
|
+
return {
|
|
547
|
+
status: 'initialized',
|
|
548
|
+
resumed: view.state === 'resumable',
|
|
549
|
+
id,
|
|
550
|
+
distribution,
|
|
551
|
+
generation: installed.pointer.generation,
|
|
552
|
+
counter: installed.pointer.counter,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/** M5.2 §4.1: every conflict is listed; `--apply` refuses at class 3 and writes nothing. */
|
|
556
|
+
function conflictRefusal(view) {
|
|
557
|
+
const message = view.conflicts.length === 1
|
|
558
|
+
? `${view.conflicts[0].reason}: ${view.conflicts[0].path}`
|
|
559
|
+
: `${view.conflicts.length} conflicts block initialization: ${view.conflicts.map((row) => `${row.path} (${row.reason})`).join('; ')}`;
|
|
560
|
+
return new Refusal('IA-CLI-CONFLICT', message, 3, { path: view.root }, `Run "${view.invocation}" without --apply to review the plan and its conflicts.`);
|
|
561
|
+
}
|
|
562
|
+
export function initEnvelope(view, apply, applied = null) {
|
|
563
|
+
const { starter } = view;
|
|
564
|
+
return {
|
|
565
|
+
version: 1,
|
|
566
|
+
command: 'init',
|
|
567
|
+
root: view.root,
|
|
568
|
+
apply,
|
|
569
|
+
plan: {
|
|
570
|
+
state: view.state,
|
|
571
|
+
owns: view.owns.map((row) => row.path),
|
|
572
|
+
conflicts: view.conflicts.map((row) => ({ path: row.path, reason: row.reason })),
|
|
573
|
+
leftovers: view.leftovers.map((path) => ({ path, reason: 'leftover temporary file' })),
|
|
574
|
+
steps: view.steps.map((row) => ({ id: row.id, status: row.status })),
|
|
575
|
+
starter: {
|
|
576
|
+
id: starter.id,
|
|
577
|
+
paths: [...starter.files.map((file) => file.path), ...(starter.descriptor === null ? [] : [DESCRIPTOR_PATH])],
|
|
578
|
+
descriptor: starter.descriptor,
|
|
579
|
+
base: starter.base,
|
|
580
|
+
provenance: {
|
|
581
|
+
status: starter.provenance.missing === null ? 'sourced' : 'local',
|
|
582
|
+
missing: starter.provenance.missing,
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
// `planned`: the host step runs after initialization, so its own plan cannot be shown before the workspace exists.
|
|
586
|
+
host: { selected: view.host, status: view.host === 'none' ? 'none' : 'planned' },
|
|
587
|
+
},
|
|
588
|
+
...(applied === null ? {} : { applied }),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
const settled = (view) => view.directory === 'absent'
|
|
592
|
+
? 'None. The target directory does not exist yet.'
|
|
593
|
+
: view.directory === 'empty'
|
|
594
|
+
? 'None. The target directory is empty.'
|
|
595
|
+
: `None. The target directory holds ${view.entries} ${view.entries === 1 ? 'entry' : 'entries'}, none of them conflicting.`;
|
|
596
|
+
const STEP_LABEL = {
|
|
597
|
+
install: 'Install',
|
|
598
|
+
system: 'System',
|
|
599
|
+
records: 'Records',
|
|
600
|
+
descriptor: 'Descriptor',
|
|
601
|
+
};
|
|
602
|
+
const stepValue = (view, step) => {
|
|
603
|
+
const pin = view.starter.base;
|
|
604
|
+
const what = step.id === 'install'
|
|
605
|
+
? `${pin.id} ${pin.version} from the bundled archive`
|
|
606
|
+
: step.path === ''
|
|
607
|
+
? 'needs a valid name'
|
|
608
|
+
: step.path;
|
|
609
|
+
return step.status === 'done' ? `${what}; already present` : what;
|
|
610
|
+
};
|
|
611
|
+
const stepRows = (view, symbol, caps) => [
|
|
612
|
+
sectionLabel('Steps', caps),
|
|
613
|
+
...fieldRows(view.steps.map((step) => ({
|
|
614
|
+
symbol: symbol(step),
|
|
615
|
+
label: STEP_LABEL[step.id],
|
|
616
|
+
value: words(stepValue(view, step)),
|
|
617
|
+
})), { depth: 1 }, caps),
|
|
618
|
+
];
|
|
619
|
+
const provenanceText = (provenance) => provenance.missing === null
|
|
620
|
+
? `${provenance.source.repository} at ${provenance.source.commit.slice(0, 12)}`
|
|
621
|
+
: `Unpublished local; ${MISSING[provenance.missing]}`;
|
|
622
|
+
/** M5.1 §7 item 4: the starter row carries the real contents rather than the plan task that specified them. */
|
|
623
|
+
const starterRows = (view, caps) => {
|
|
624
|
+
const { starter } = view, pin = starter.base;
|
|
625
|
+
const rows = starter.name === null || starter.id === null
|
|
626
|
+
? [{ symbol: 'info', label: 'Starter records', value: words('Not planned until the target has a valid name') }]
|
|
627
|
+
: [
|
|
628
|
+
{
|
|
629
|
+
symbol: 'info',
|
|
630
|
+
label: 'Starter records',
|
|
631
|
+
value: words(`@system ${starter.name} and its steward; @workspace ${starter.name} composing ${starter.systems.length} systems; @distribution ${starter.name}-distribution`),
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
symbol: 'info',
|
|
635
|
+
label: 'Release',
|
|
636
|
+
value: words(`${starter.id} ${STARTER_VERSION}, requiring ${pin.id} ^${pin.version}`),
|
|
637
|
+
},
|
|
638
|
+
{ symbol: 'info', label: 'Provenance', value: words(provenanceText(starter.provenance)) },
|
|
639
|
+
];
|
|
640
|
+
return [
|
|
641
|
+
sectionLabel('Starter', caps),
|
|
642
|
+
...fieldRows([
|
|
643
|
+
...rows,
|
|
644
|
+
{
|
|
645
|
+
symbol: 'info',
|
|
646
|
+
label: 'Base package',
|
|
647
|
+
value: [atom(`${pin.id} ${pin.version}`), atom(`sha256 ${truncateDigest(pin.archive, caps.ascii)}`, null, 2)],
|
|
648
|
+
},
|
|
649
|
+
], { depth: 1 }, caps),
|
|
650
|
+
];
|
|
651
|
+
};
|
|
652
|
+
const planBlocks = (view, caps) => {
|
|
653
|
+
const conflicts = view.conflicts.map((row) => ({
|
|
654
|
+
symbol: 'warning',
|
|
655
|
+
label: row.path,
|
|
656
|
+
value: words(row.reason),
|
|
657
|
+
}));
|
|
658
|
+
return [
|
|
659
|
+
[
|
|
660
|
+
sectionLabel('Would own', caps),
|
|
661
|
+
...fieldRows(view.owns.map((row) => ({ symbol: 'added', label: row.path, value: words(row.purpose) })), { depth: 1 }, caps),
|
|
662
|
+
],
|
|
663
|
+
[
|
|
664
|
+
sectionLabel('Conflicts', caps),
|
|
665
|
+
...(conflicts.length === 0
|
|
666
|
+
? entry([words(settled(view))], { depth: 1, symbol: 'success' }, caps)
|
|
667
|
+
: fieldRows(conflicts, { depth: 1 }, caps)),
|
|
668
|
+
],
|
|
669
|
+
...(view.leftovers.length === 0
|
|
670
|
+
? []
|
|
671
|
+
: [
|
|
672
|
+
[
|
|
673
|
+
sectionLabel('Leftovers', caps),
|
|
674
|
+
...fieldRows(view.leftovers.map((path) => ({
|
|
675
|
+
symbol: 'removed',
|
|
676
|
+
label: path,
|
|
677
|
+
value: words('leftover temporary file; removed before writing'),
|
|
678
|
+
})), { depth: 1 }, caps),
|
|
679
|
+
],
|
|
680
|
+
]),
|
|
681
|
+
starterRows(view, caps),
|
|
682
|
+
stepRows(view, (step) => (step.status === 'done' ? 'success' : 'added'), caps),
|
|
683
|
+
[
|
|
684
|
+
sectionLabel('Host', caps),
|
|
685
|
+
...fieldRows([
|
|
686
|
+
view.host === 'none'
|
|
687
|
+
? { symbol: 'info', label: 'Host integration', value: words('--host none selected; no host is registered') }
|
|
688
|
+
: {
|
|
689
|
+
symbol: 'added',
|
|
690
|
+
label: 'Host integration',
|
|
691
|
+
value: words(`--host ${view.host} selected; registered after initialization by ia host`),
|
|
692
|
+
},
|
|
693
|
+
], { depth: 1 }, caps),
|
|
694
|
+
],
|
|
695
|
+
];
|
|
696
|
+
};
|
|
697
|
+
export function renderInit(view, caps) {
|
|
698
|
+
const next = view.state === 'conflict'
|
|
699
|
+
? `Resolve the conflicts above, then run "${view.invocation} --apply --yes".`
|
|
700
|
+
: `Apply with "${view.invocation} --apply --yes".${view.state === 'resumable' ? ' Completed steps are skipped.' : ''}`;
|
|
701
|
+
return document([
|
|
702
|
+
headerLine('Plan', 'init', [{ text: view.root, column: 50 }], caps),
|
|
703
|
+
entry([words('This is a preview. Nothing has been written.')], { depth: 1 }, caps),
|
|
704
|
+
...planBlocks(view, caps),
|
|
705
|
+
entry([words(next)], { depth: 0, symbol: 'step' }, caps),
|
|
706
|
+
], { leadingBlank: true });
|
|
707
|
+
}
|
|
708
|
+
/** §2.8 rule 3's change summary for `init`: the plan's own blocks without the preview note or the footer. */
|
|
709
|
+
export const renderInitSummary = (view, caps) => document([headerLine('Plan', 'init', [{ text: view.root, column: 50 }], caps), ...planBlocks(view, caps)], {
|
|
710
|
+
leadingBlank: true,
|
|
711
|
+
});
|
|
712
|
+
export function renderApplied(view, applied, caps) {
|
|
713
|
+
return document([
|
|
714
|
+
headerLine('Init', applied.id, [{ text: view.root, column: 50 }], caps),
|
|
715
|
+
entry([
|
|
716
|
+
words(`${applied.resumed ? 'Finished initializing' : 'Initialized'} ${applied.id}; release descriptor ${DESCRIPTOR_PATH}.`),
|
|
717
|
+
words(`Installed generation ${truncateDigest(applied.generation, caps.ascii)}, counter ${applied.counter}.`),
|
|
718
|
+
], { depth: 1, symbol: 'success' }, caps),
|
|
719
|
+
// Host registration spec §4: written is all this can say; `ia doctor` reports what is observed.
|
|
720
|
+
...(applied.host === undefined
|
|
721
|
+
? []
|
|
722
|
+
: [
|
|
723
|
+
entry([words(`Registered with ${view.host}; written, not observed answering.`)], { depth: 1, symbol: 'success' }, caps),
|
|
724
|
+
]),
|
|
725
|
+
stepRows(view, () => 'success', caps),
|
|
726
|
+
// Spec §5.3 and §5.4: the notes `ia host` prints after a registration, from the same function.
|
|
727
|
+
...(applied.host === undefined || view.host === 'none' ? [] : hostNotes(view.host, caps)),
|
|
728
|
+
entry([
|
|
729
|
+
words('Run "ia validate" to check the workspace, or "ia pack --descriptor .ia/release.json" to build its release.'),
|
|
730
|
+
], { depth: 0, symbol: 'step' }, caps),
|
|
731
|
+
], { leadingBlank: true });
|
|
732
|
+
}
|
|
733
|
+
const renderDeclined = (view, caps) => document([
|
|
734
|
+
entry([words('Nothing was applied.')], { depth: 0, symbol: 'info' }, caps),
|
|
735
|
+
entry([words(`Apply with "${view.invocation} --apply --yes".`)], { depth: 0, symbol: 'step' }, caps),
|
|
736
|
+
], { leadingBlank: true });
|
|
737
|
+
function invocationOf(context) {
|
|
738
|
+
const { args } = context;
|
|
739
|
+
const parts = ['ia init', ...args.positionals.map(quote)];
|
|
740
|
+
const id = args.value('id'), host = args.value('host');
|
|
741
|
+
if (id !== undefined)
|
|
742
|
+
parts.push('--id', quote(id));
|
|
743
|
+
if (host !== undefined && host !== 'none')
|
|
744
|
+
parts.push('--host', host);
|
|
745
|
+
return parts.join(' ');
|
|
746
|
+
}
|
|
747
|
+
export async function runInit(context) {
|
|
748
|
+
const { args, caps, json, host } = context;
|
|
749
|
+
const id = args.value('id');
|
|
750
|
+
// A malformed identity is usage (§3), decided before the target is read.
|
|
751
|
+
if (id !== undefined && (!PACKAGE_ID.test(id) || id.includes('..')))
|
|
752
|
+
throw new UsageError(`--id must be <provider/name>; got ${id}`);
|
|
753
|
+
const root = resolveTarget(host.cwd, args.positionals[0]);
|
|
754
|
+
// Host plugin distribution spec §7.2: a decline is recorded before anything about the target is written or git is run.
|
|
755
|
+
// It reports the target as typed, resolved to an absolute path; its decision key resolves links itself.
|
|
756
|
+
const decline = args.value('decline');
|
|
757
|
+
if (decline !== undefined || args.flag('forget-decline'))
|
|
758
|
+
return runDecline(context, resolve(host.cwd, args.positionals[0] ?? '.'), decline);
|
|
759
|
+
// Host plugin distribution spec §3: a workspace is never created in, around or inside the IA home, and the service
|
|
760
|
+
// raises IA-DIST-PATH-UNSAFE for either nesting, as `ia host` does. A malformed IA_HOME is left to the verbs that
|
|
761
|
+
// need the home, so `ia init --host none` behaves as before (contract §2.1).
|
|
762
|
+
const iaHome = iaHomeOf(host.env);
|
|
763
|
+
if (iaHome !== undefined)
|
|
764
|
+
located(root, 'Initialize a different directory, or set IA_HOME to an absolute directory outside it.', () => assertHomeOutsideWorkspace(iaHome, root));
|
|
765
|
+
const selected = (args.value('host') ?? 'none');
|
|
766
|
+
const apply = args.flag('apply');
|
|
767
|
+
const existing = statSync(root, { throwIfNoEntry: false }) === undefined ? dirname(root) : root;
|
|
768
|
+
const view = collectInit({
|
|
769
|
+
root,
|
|
770
|
+
host: selected,
|
|
771
|
+
id,
|
|
772
|
+
packageRoot: host.packageRoot,
|
|
773
|
+
git: gitIn(existing, host.env),
|
|
774
|
+
invocation: invocationOf(context),
|
|
775
|
+
});
|
|
776
|
+
const rendered = (applied) => json
|
|
777
|
+
? { exitCode: 0, stdout: JSON.stringify(initEnvelope(view, apply, applied)) + '\n', stderr: '' }
|
|
778
|
+
: {
|
|
779
|
+
exitCode: 0,
|
|
780
|
+
stdout: applied === null ? renderInit(view, caps) : renderApplied(view, applied, caps),
|
|
781
|
+
stderr: '',
|
|
782
|
+
};
|
|
783
|
+
// §2.1: conflicts are reported inside the plan and are not an error class, so a plan is always exit 0.
|
|
784
|
+
if (!apply)
|
|
785
|
+
return rendered(null);
|
|
786
|
+
if (view.state !== 'fresh' && view.state !== 'resumable')
|
|
787
|
+
throw conflictRefusal(view);
|
|
788
|
+
// §2.8 rule 3, as `ia install` asks it: parsing already refused every `--apply` whose question cannot be answered.
|
|
789
|
+
if (!args.flag('yes') && !(await confirm(host.interaction, renderInitSummary(view, caps), CONFIRMATION)))
|
|
790
|
+
return { exitCode: 0, stdout: renderDeclined(view, caps), stderr: '' };
|
|
791
|
+
const initialized = await applyInit(view, { packageRoot: host.packageRoot, signal: host.signal });
|
|
792
|
+
clearDecline(host.env, root);
|
|
793
|
+
if (selected === 'none')
|
|
794
|
+
return rendered(initialized);
|
|
795
|
+
return rendered({ ...initialized, host: registerHost(context, selected, root) });
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Host registration spec §4 "init --host": the `ia host <host> --apply` path over the workspace just initialized.
|
|
799
|
+
* Its arguments come from `ia host`'s own grammar rather than a hand-built object, so the host step reads exactly
|
|
800
|
+
* what `ia host` would. `--yes` stands for the answer this invocation already gave, by flag or at the prompt, so
|
|
801
|
+
* the step never asks a second question. Every failure here happens after initialization completed: the workspace
|
|
802
|
+
* stays initialized, and each refusal keeps the service's code, message and location (contract §4.1).
|
|
803
|
+
*
|
|
804
|
+
* Its next action is `ia host`'s own remedy for that refusal, after a sentence saying initialization completed. Every
|
|
805
|
+
* `ia host` command the remedy names gains `--root <root>`, because `<directory>` may be relative to a cwd that
|
|
806
|
+
* is not the workspace, and `ia host` would otherwise discover a different root or none. A failure `ia host` gives
|
|
807
|
+
* no remedy for falls back to the rerun that finishes registration.
|
|
808
|
+
*/
|
|
809
|
+
function registerHost(context, selected, root) {
|
|
810
|
+
const finish = `Run "ia host ${selected} --root ${quote(root)} --apply" to finish host registration.`;
|
|
811
|
+
const composed = (next) => `The workspace is initialized. ${next === null ? finish : rootedNext(next, selected, root)}`;
|
|
812
|
+
// M5.2 §5: a signal observed after the last init write interrupts a command that has written, so it names the rerun.
|
|
813
|
+
if (context.host.signal?.aborted)
|
|
814
|
+
throw new Interrupted(composed(null));
|
|
815
|
+
const command = findCommand('host');
|
|
816
|
+
const args = parseArguments([selected, '--root', root, '--apply', '--yes'], command.grammar);
|
|
817
|
+
try {
|
|
818
|
+
return applyHostSet(collectHost({ ...context, command, args }), context.host.packageRoot, context.host.signal);
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
if (error instanceof Interrupted)
|
|
822
|
+
throw new Interrupted(composed(error.next));
|
|
823
|
+
const refusal = refusalOf(error);
|
|
824
|
+
throw new Refusal(refusal.code, refusal.message, refusal.exit, refusal.where, composed(refusal.next));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* A finished git run's answer, or null when git is not really there: it could not start, or it is the macOS stub
|
|
829
|
+
* `/usr/bin/git`, which only asks for the Xcode Command Line Tools and fails; reading that as "not a Git checkout" would
|
|
830
|
+
* send the user after the wrong fix (#323).
|
|
831
|
+
*/
|
|
832
|
+
export function gitAnswer(result) {
|
|
833
|
+
if (result.error !== undefined)
|
|
834
|
+
return null;
|
|
835
|
+
if (result.status !== 0 &&
|
|
836
|
+
/^xcrun: error:|^xcode-select: (?:note|error): No developer tools were found/m.test(result.stderr ?? ''))
|
|
837
|
+
return null;
|
|
838
|
+
return { status: result.status, stdout: result.stdout };
|
|
839
|
+
}
|
|
840
|
+
//# sourceMappingURL=init.js.map
|