@aventara/client 0.0.0-stage → 0.1.0-pilot.1
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 +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +234 -2
- package/dist/avclient.bin.d.ts +2 -0
- package/dist/avclient.bin.js +15 -0
- package/dist/cli/command.parser.d.ts +37 -0
- package/dist/cli/command.parser.js +177 -0
- package/dist/cli/generate.command.d.ts +24 -0
- package/dist/cli/generate.command.js +41 -0
- package/dist/cli/generation-failure.renderer.d.ts +6 -0
- package/dist/cli/generation-failure.renderer.js +52 -0
- package/dist/cli/generation-success.renderer.d.ts +32 -0
- package/dist/cli/generation-success.renderer.js +47 -0
- package/dist/cli/terminal.prompter.d.ts +13 -0
- package/dist/cli/terminal.prompter.js +53 -0
- package/dist/cli/warning.renderer.d.ts +10 -0
- package/dist/cli/warning.renderer.js +14 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +75 -0
- package/dist/config/client-config.interface.d.ts +62 -0
- package/dist/config/client-config.interface.js +14 -0
- package/dist/config/config.loader.d.ts +41 -0
- package/dist/config/config.loader.js +95 -0
- package/dist/config/config.resolver.d.ts +50 -0
- package/dist/config/config.resolver.js +126 -0
- package/dist/config/env.cascade.d.ts +84 -0
- package/dist/config/env.cascade.js +126 -0
- package/dist/contract/contract.acceptance.d.ts +77 -0
- package/dist/contract/contract.acceptance.js +124 -0
- package/dist/contract/contract.fetcher.d.ts +64 -0
- package/dist/contract/contract.fetcher.js +85 -0
- package/dist/contract/contract.loader.d.ts +32 -0
- package/dist/contract/contract.loader.js +32 -0
- package/dist/emit/banner.emitter.d.ts +31 -0
- package/dist/emit/banner.emitter.js +42 -0
- package/dist/emit/client-surface.emitter.d.ts +32 -0
- package/dist/emit/client-surface.emitter.js +236 -0
- package/dist/emit/client-tree.emitter.d.ts +37 -0
- package/dist/emit/client-tree.emitter.js +103 -0
- package/dist/emit/contract-carrier.emitter.d.ts +13 -0
- package/dist/emit/contract-carrier.emitter.js +60 -0
- package/dist/emit/derivation.emitter.d.ts +45 -0
- package/dist/emit/derivation.emitter.js +233 -0
- package/dist/emit/descriptor.emitter.d.ts +4 -0
- package/dist/emit/descriptor.emitter.js +97 -0
- package/dist/emit/emitted-tree.interface.d.ts +61 -0
- package/dist/emit/emitted-tree.interface.js +18 -0
- package/dist/emit/enum.emitter.d.ts +24 -0
- package/dist/emit/enum.emitter.js +42 -0
- package/dist/emit/name.deriver.d.ts +153 -0
- package/dist/emit/name.deriver.js +411 -0
- package/dist/emit/named-type.emitter.d.ts +32 -0
- package/dist/emit/named-type.emitter.js +50 -0
- package/dist/emit/runtime.emitter.d.ts +87 -0
- package/dist/emit/runtime.emitter.js +707 -0
- package/dist/emit/scalar.codec.d.ts +63 -0
- package/dist/emit/scalar.codec.js +498 -0
- package/dist/emit/transaction.emitter.d.ts +17 -0
- package/dist/emit/transaction.emitter.js +438 -0
- package/dist/generate.d.ts +123 -0
- package/dist/generate.js +98 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/init/client-config.template.d.ts +11 -0
- package/dist/init/client-config.template.js +27 -0
- package/dist/init/client-init.errors.d.ts +9 -0
- package/dist/init/client-init.errors.js +9 -0
- package/dist/init/client-init.orchestrator.d.ts +3 -0
- package/dist/init/client-init.orchestrator.js +86 -0
- package/dist/init/client-init.planner.d.ts +27 -0
- package/dist/init/client-init.planner.js +99 -0
- package/dist/init/client-init.questions.d.ts +52 -0
- package/dist/init/client-init.questions.js +124 -0
- package/dist/init/client-project.inspector.d.ts +15 -0
- package/dist/init/client-project.inspector.js +32 -0
- package/dist/init/command.runner.d.ts +8 -0
- package/dist/init/command.runner.js +17 -0
- package/dist/node-version.guard.d.ts +8 -0
- package/dist/node-version.guard.js +59 -0
- package/dist/output/output.validator.d.ts +75 -0
- package/dist/output/output.validator.js +262 -0
- package/dist/output/output.writer.d.ts +162 -0
- package/dist/output/output.writer.js +499 -0
- package/package.json +47 -3
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as fsPromises from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { carriesGeneratedOwnership, GENERATED_OWNERSHIP_HEAD_BYTES, } from "../emit/banner.emitter.js";
|
|
5
|
+
import { CLIENT_ENTRY_FILE, GENERATED_DIRECTORY, } from "../emit/emitted-tree.interface.js";
|
|
6
|
+
import { resolveInstalledTypeScript, validateOutputTree, } from "./output.validator.js";
|
|
7
|
+
/** A write that stopped; the previous output is intact unless it says otherwise. */
|
|
8
|
+
export class OutputWriteError extends Error {
|
|
9
|
+
stage;
|
|
10
|
+
name = "OutputWriteError";
|
|
11
|
+
/**
|
|
12
|
+
* What the run said before it stopped, in the order a success returns its
|
|
13
|
+
* warnings: the emission's, the validator's, the writer's. Printed before the
|
|
14
|
+
* refusal's sentence.
|
|
15
|
+
*/
|
|
16
|
+
warnings;
|
|
17
|
+
constructor(stage, message, options) {
|
|
18
|
+
super(message, options);
|
|
19
|
+
this.stage = stage;
|
|
20
|
+
this.warnings = options?.warnings ?? [];
|
|
21
|
+
}
|
|
22
|
+
/** The same refusal, with `earlier` said before the warnings it already carries. */
|
|
23
|
+
precededBy(earlier) {
|
|
24
|
+
return new OutputWriteError(this.stage, this.message, {
|
|
25
|
+
cause: this.cause,
|
|
26
|
+
warnings: [...earlier, ...this.warnings],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What a run said before a defect stopped it, kept beside the defect rather than
|
|
32
|
+
* on a wrapper: a defect propagates as the very error thrown, with its own stack,
|
|
33
|
+
* so a caller matching it by identity or type still can. Keyed weakly, so it
|
|
34
|
+
* lives exactly as long as the error does. A thrown primitive cannot be a key;
|
|
35
|
+
* what was said before one is not kept.
|
|
36
|
+
*/
|
|
37
|
+
const WARNINGS_BEFORE_DEFECT = new WeakMap();
|
|
38
|
+
/**
|
|
39
|
+
* Records `earlier` as said before `defect`, ahead of anything already recorded,
|
|
40
|
+
* and returns `defect` unchanged so the caller rethrows the same error.
|
|
41
|
+
*/
|
|
42
|
+
export function precedeDefect(defect, earlier) {
|
|
43
|
+
if (earlier.length > 0 && typeof defect === "object" && defect !== null) {
|
|
44
|
+
WARNINGS_BEFORE_DEFECT.set(defect, [
|
|
45
|
+
...earlier,
|
|
46
|
+
...warningsRaisedBeforeDefect(defect),
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
return defect;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* What the run said before `defect` stopped it, in the order a success returns
|
|
53
|
+
* its warnings; empty when nothing was, or for an error no run threw.
|
|
54
|
+
*/
|
|
55
|
+
export function warningsRaisedBeforeDefect(defect) {
|
|
56
|
+
return typeof defect === "object" && defect !== null
|
|
57
|
+
? (WARNINGS_BEFORE_DEFECT.get(defect) ?? [])
|
|
58
|
+
: [];
|
|
59
|
+
}
|
|
60
|
+
const NEXT_PREFIX = ".aventara-next-";
|
|
61
|
+
const READY_PREFIX = ".aventara-ready-";
|
|
62
|
+
const PREVIOUS_GENERATED = `.${GENERATED_DIRECTORY}.aventara-previous`;
|
|
63
|
+
/**
|
|
64
|
+
* Writes `emission.tree` as `<generateAt>/AvClient.ts` and `<generateAt>/generated/`.
|
|
65
|
+
*
|
|
66
|
+
* @throws OutputWriteError when the write stops; the previous output is intact.
|
|
67
|
+
*/
|
|
68
|
+
export async function writeClientOutput(input) {
|
|
69
|
+
const fs = input.fileSystem ?? fsPromises;
|
|
70
|
+
const generateAt = path.resolve(input.generateAt);
|
|
71
|
+
const paths = {
|
|
72
|
+
generateAt,
|
|
73
|
+
generated: path.join(generateAt, GENERATED_DIRECTORY),
|
|
74
|
+
entry: path.join(generateAt, CLIENT_ENTRY_FILE),
|
|
75
|
+
previous: path.join(generateAt, PREVIOUS_GENERATED),
|
|
76
|
+
};
|
|
77
|
+
const writerWarnings = [];
|
|
78
|
+
let validationWarnings = [];
|
|
79
|
+
/** The outermost directory this run created on the way to `generateAt`, if any. */
|
|
80
|
+
let created;
|
|
81
|
+
try {
|
|
82
|
+
return await writeStagedOutput();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (created !== undefined) {
|
|
86
|
+
writerWarnings.push(...(await removeCreatedDirectories(fs, generateAt, created)));
|
|
87
|
+
}
|
|
88
|
+
const said = [
|
|
89
|
+
...input.emission.warnings,
|
|
90
|
+
...validationWarnings,
|
|
91
|
+
...writerWarnings,
|
|
92
|
+
];
|
|
93
|
+
throw error instanceof OutputWriteError
|
|
94
|
+
? error.precededBy(said)
|
|
95
|
+
: precedeDefect(error, said);
|
|
96
|
+
}
|
|
97
|
+
async function writeStagedOutput() {
|
|
98
|
+
// prepare — recover a killed run, then decide whether the two entries are ours.
|
|
99
|
+
const state = await stage("prepare", paths, async () => {
|
|
100
|
+
created = await ensureDirectory(fs, generateAt);
|
|
101
|
+
writerWarnings.push(...(await recoverInterruptedRun(fs, paths)));
|
|
102
|
+
const inspection = await inspectOwnedEntries(fs, paths);
|
|
103
|
+
const confirmed = new Set(input.overrideForeign ?? []);
|
|
104
|
+
const unconfirmed = inspection.foreign.filter((entry) => !confirmed.has(entry));
|
|
105
|
+
if (unconfirmed.length > 0) {
|
|
106
|
+
throw foreignContentRefusal(paths, unconfirmed);
|
|
107
|
+
}
|
|
108
|
+
return inspection;
|
|
109
|
+
});
|
|
110
|
+
// temp — the whole tree, AvClient.ts and generated/ together, staged in generateAt.
|
|
111
|
+
const suffix = randomBytes(6).toString("hex");
|
|
112
|
+
let staging = path.join(generateAt, `${NEXT_PREFIX}${suffix}`);
|
|
113
|
+
let stagingExists = false;
|
|
114
|
+
try {
|
|
115
|
+
await stage("temp", paths, async () => {
|
|
116
|
+
// `mkdir`, not `mkdtemp`: generated/ is moved out of this directory and
|
|
117
|
+
// takes the mode any directory the user creates takes (`mkdtemp` makes 0700).
|
|
118
|
+
await fs.mkdir(staging);
|
|
119
|
+
stagingExists = true;
|
|
120
|
+
for (const file of input.emission.tree) {
|
|
121
|
+
const target = path.join(staging, file.path);
|
|
122
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
123
|
+
await fs.writeFile(target, file.bytes);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
// validate — Q6, over AvClient.ts and generated/ as one program.
|
|
127
|
+
const validation = await stage("validate", paths, () => validateOutputTree(staging, input.emission.tree, input.resolveTypeScript ?? resolveInstalledTypeScript));
|
|
128
|
+
if (validation.accepted) {
|
|
129
|
+
validationWarnings = validation.warnings;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
throw new OutputWriteError("validate", `the generated output failed its ${validation.checked} check, so ${LEFT_AS_THEY_WERE(paths)} ` +
|
|
133
|
+
"This is a defect in @aventara/client; please report it with these findings:\n" +
|
|
134
|
+
validation.findings.map((finding) => ` ${finding}`).join("\n"));
|
|
135
|
+
}
|
|
136
|
+
// replace — the order and its rollbacks are the module doc's.
|
|
137
|
+
await stage("replace", paths, async () => {
|
|
138
|
+
const ready = path.join(generateAt, `${READY_PREFIX}${suffix}`);
|
|
139
|
+
await fs.rename(staging, ready);
|
|
140
|
+
staging = ready;
|
|
141
|
+
const movedAside = state.generated === "present";
|
|
142
|
+
if (movedAside) {
|
|
143
|
+
await fs.rename(paths.generated, paths.previous);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await fs.rename(path.join(staging, GENERATED_DIRECTORY), paths.generated);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (movedAside) {
|
|
150
|
+
await rollBack(paths, error, () => fs.rename(paths.previous, paths.generated));
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await fs.rename(path.join(staging, CLIENT_ENTRY_FILE), paths.entry);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
await rollBack(paths, error, async () => {
|
|
159
|
+
await fs.rename(paths.generated, path.join(staging, GENERATED_DIRECTORY));
|
|
160
|
+
if (movedAside) {
|
|
161
|
+
await fs.rename(paths.previous, paths.generated);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
if (state.generated === "present") {
|
|
168
|
+
try {
|
|
169
|
+
await fs.rm(paths.previous, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
writerWarnings.push(`the previous generated/ was replaced, but its copy at ${paths.previous} could not be removed ` +
|
|
173
|
+
`(${describe(error)}); the next run removes it.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
generateAt: generateAt,
|
|
178
|
+
checked: validation.checked,
|
|
179
|
+
warnings: [
|
|
180
|
+
...input.emission.warnings,
|
|
181
|
+
...validationWarnings,
|
|
182
|
+
...writerWarnings,
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
if (stagingExists) {
|
|
188
|
+
await fs.rm(staging, { recursive: true, force: true }).catch(() => {
|
|
189
|
+
// The next run removes it by name; the error being thrown matters more.
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** The sentence's ending when the previous output was not changed. */
|
|
196
|
+
function LEFT_AS_THEY_WERE(paths) {
|
|
197
|
+
return `${CLIENT_ENTRY_FILE} and ${GENERATED_DIRECTORY}/ in ${paths.generateAt} were left exactly as they were.`;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Creates `generateAt` when missing; refuses when something other than a
|
|
201
|
+
* directory is there.
|
|
202
|
+
*
|
|
203
|
+
* @returns the outermost directory it created — `generateAt` or an ancestor —
|
|
204
|
+
* or `undefined` when `generateAt` already existed.
|
|
205
|
+
*/
|
|
206
|
+
async function ensureDirectory(fs, generateAt) {
|
|
207
|
+
const stats = await lstatOrUndefined(fs, generateAt);
|
|
208
|
+
if (stats === undefined) {
|
|
209
|
+
return fs.mkdir(generateAt, { recursive: true });
|
|
210
|
+
}
|
|
211
|
+
if (!stats.isDirectory()) {
|
|
212
|
+
throw notADirectory(generateAt);
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Removes, after a failed run, the directories it created: `generateAt` and each
|
|
218
|
+
* parent up to `outermost`, innermost first. `rmdir` removes only an empty
|
|
219
|
+
* directory, so nothing anyone else put there meanwhile is lost; the first one
|
|
220
|
+
* that cannot be removed ends the walk, and is named in the warning returned.
|
|
221
|
+
*/
|
|
222
|
+
async function removeCreatedDirectories(fs, generateAt, outermost) {
|
|
223
|
+
for (let directory = generateAt;; directory = path.dirname(directory)) {
|
|
224
|
+
try {
|
|
225
|
+
await fs.rmdir(directory);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
return [
|
|
229
|
+
`${directory}, which this run created, could not be removed (${describe(error)}); ` +
|
|
230
|
+
"remove it if you do not want it.",
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
if (directory === outermost || path.dirname(directory) === directory) {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Leftovers of a run that was killed, recognised only by the names this writer
|
|
240
|
+
* gives them:
|
|
241
|
+
*
|
|
242
|
+
* - a staging directory that never became ready was not validated: removed;
|
|
243
|
+
* - a ready one whose `generated/` was already moved in but whose `AvClient.ts`
|
|
244
|
+
* was not: its `AvClient.ts` is moved in, completing the new pair;
|
|
245
|
+
* - any other ready one: removed;
|
|
246
|
+
* - a previous `generated/` moved aside: put back when nothing replaced it,
|
|
247
|
+
* removed when something did.
|
|
248
|
+
*/
|
|
249
|
+
async function recoverInterruptedRun(fs, paths) {
|
|
250
|
+
const warnings = [];
|
|
251
|
+
for (const entry of (await fs.readdir(paths.generateAt)).sort()) {
|
|
252
|
+
const leftover = path.join(paths.generateAt, entry);
|
|
253
|
+
if (entry.startsWith(READY_PREFIX)) {
|
|
254
|
+
const stagedEntry = path.join(leftover, CLIENT_ENTRY_FILE);
|
|
255
|
+
const generatedMovedIn = (await lstatOrUndefined(fs, path.join(leftover, GENERATED_DIRECTORY))) === undefined;
|
|
256
|
+
if (generatedMovedIn &&
|
|
257
|
+
(await lstatOrUndefined(fs, stagedEntry)) !== undefined) {
|
|
258
|
+
await fs.rename(stagedEntry, paths.entry);
|
|
259
|
+
warnings.push(`an interrupted run had moved its generated/ in but not its AvClient.ts; ` +
|
|
260
|
+
`the validated AvClient.ts was moved in at ${paths.entry} before generating.`);
|
|
261
|
+
}
|
|
262
|
+
await fs.rm(leftover, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
else if (entry.startsWith(NEXT_PREFIX)) {
|
|
265
|
+
await fs.rm(leftover, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if ((await lstatOrUndefined(fs, paths.previous)) === undefined) {
|
|
269
|
+
return warnings;
|
|
270
|
+
}
|
|
271
|
+
if ((await generatedAfterRecovery(fs, paths)) === paths.previous) {
|
|
272
|
+
await fs.rename(paths.previous, paths.generated);
|
|
273
|
+
warnings.push(`an interrupted run had moved the previous generated/ aside; it was put back at ${paths.generated} before generating.`);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
await fs.rm(paths.previous, { recursive: true, force: true });
|
|
277
|
+
}
|
|
278
|
+
return warnings;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Where the `generated/` a run works with comes from once a killed run is
|
|
282
|
+
* recovered: the previous one moved aside when nothing replaced it — recovery
|
|
283
|
+
* puts it back — and `generated/` otherwise. One answer for the recovery and for
|
|
284
|
+
* the read-only look ahead of it, so the two cannot disagree.
|
|
285
|
+
*/
|
|
286
|
+
async function generatedAfterRecovery(fs, paths) {
|
|
287
|
+
return (await lstatOrUndefined(fs, paths.generated)) === undefined &&
|
|
288
|
+
(await lstatOrUndefined(fs, paths.previous)) !== undefined
|
|
289
|
+
? paths.previous
|
|
290
|
+
: paths.generated;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Lists what in `<generateAt>/AvClient.ts` and `<generateAt>/generated/` the
|
|
294
|
+
* generator did not produce — a file without the ownership line, a symbolic link,
|
|
295
|
+
* anything that is not a regular file — so the person running it can be asked
|
|
296
|
+
* before a generation overwrites or removes it (architect, 2026-10-04). Reads
|
|
297
|
+
* only; looks at nothing else in `generateAt`. Empty when `generateAt` does not
|
|
298
|
+
* exist yet.
|
|
299
|
+
*
|
|
300
|
+
* It sees what the write will see once it has recovered a killed run, without
|
|
301
|
+
* recovering it: a `generated/` the killed run moved aside with nothing in its
|
|
302
|
+
* place is read where it lies and its files named where recovery puts them back
|
|
303
|
+
* (`generated/…`). So the question asked, and `--yes`, cover them too — and a
|
|
304
|
+
* run that stops here has still touched nothing.
|
|
305
|
+
*
|
|
306
|
+
* @throws OutputWriteError when `generateAt` is not a directory, `generated` is
|
|
307
|
+
* not a directory or `AvClient.ts` is not a file: no confirmation repairs that.
|
|
308
|
+
*/
|
|
309
|
+
export async function findForeignOutputContent(generateAt, fileSystem = fsPromises) {
|
|
310
|
+
const root = path.resolve(generateAt);
|
|
311
|
+
const stats = await lstatOrUndefined(fileSystem, root);
|
|
312
|
+
if (stats === undefined) {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
if (!stats.isDirectory()) {
|
|
316
|
+
throw notADirectory(root);
|
|
317
|
+
}
|
|
318
|
+
const paths = {
|
|
319
|
+
generateAt: root,
|
|
320
|
+
generated: path.join(root, GENERATED_DIRECTORY),
|
|
321
|
+
entry: path.join(root, CLIENT_ENTRY_FILE),
|
|
322
|
+
previous: path.join(root, PREVIOUS_GENERATED),
|
|
323
|
+
};
|
|
324
|
+
const inspection = await inspectOwnedEntries(fileSystem, paths, await generatedAfterRecovery(fileSystem, paths));
|
|
325
|
+
return inspection.foreign;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* What stands at the two owned entries. `generatedSource` is the directory read
|
|
329
|
+
* as `generated/` — `generated/` itself, or the previous one a killed run moved
|
|
330
|
+
* aside, which recovery will put back there — and its files are named as
|
|
331
|
+
* `generated/…` either way.
|
|
332
|
+
*/
|
|
333
|
+
async function inspectOwnedEntries(fs, paths, generatedSource = paths.generated) {
|
|
334
|
+
const foreign = [];
|
|
335
|
+
const relative = (file) => path.relative(paths.generateAt, file).split(path.sep).join("/");
|
|
336
|
+
const generatedStats = await lstatOrUndefined(fs, generatedSource);
|
|
337
|
+
if (generatedStats !== undefined) {
|
|
338
|
+
if (!generatedStats.isDirectory()) {
|
|
339
|
+
throw new OutputWriteError("prepare", `refusing to replace ${generatedSource}: it is not a directory, and the generator owns ` +
|
|
340
|
+
`${GENERATED_DIRECTORY}/ as one; move it, or choose another \`generateAt\`. Nothing was written.`);
|
|
341
|
+
}
|
|
342
|
+
const entries = await fs.readdir(generatedSource, {
|
|
343
|
+
recursive: true,
|
|
344
|
+
withFileTypes: true,
|
|
345
|
+
});
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
if (entry.isDirectory()) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const file = path.join(entry.parentPath, entry.name);
|
|
351
|
+
if (!entry.isFile() || !(await carriesOwnershipLine(fs, file))) {
|
|
352
|
+
foreign.push(relative(path.join(paths.generated, path.relative(generatedSource, file))));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const entryStats = await lstatOrUndefined(fs, paths.entry);
|
|
357
|
+
if (entryStats !== undefined) {
|
|
358
|
+
if (!entryStats.isFile()) {
|
|
359
|
+
throw new OutputWriteError("prepare", `refusing to replace ${paths.entry}: it is not a file, and the generator owns ${CLIENT_ENTRY_FILE} ` +
|
|
360
|
+
"as one; move it, or choose another `generateAt`. Nothing was written.");
|
|
361
|
+
}
|
|
362
|
+
if (!(await carriesOwnershipLine(fs, paths.entry))) {
|
|
363
|
+
foreign.push(relative(paths.entry));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
generated: generatedStats === undefined ? "absent" : "present",
|
|
368
|
+
entry: entryStats === undefined ? "absent" : "present",
|
|
369
|
+
foreign: foreign.sort(),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/** Foreign content nobody confirmed may be overwritten. */
|
|
373
|
+
function foreignContentRefusal(paths, foreign) {
|
|
374
|
+
const them = foreign.length === 1 ? "it" : "them";
|
|
375
|
+
return new OutputWriteError("prepare", `refusing to generate into ${paths.generateAt}: ${foreign.join(", ")} ${foreign.length === 1 ? "was" : "were"} ` +
|
|
376
|
+
`not generated by @aventara/client, and generating would overwrite or remove ${them}; confirm the ` +
|
|
377
|
+
`override, move ${them} out, or choose another \`generateAt\`. Nothing was written.`);
|
|
378
|
+
}
|
|
379
|
+
function notADirectory(generateAt) {
|
|
380
|
+
return new OutputWriteError("prepare", `refusing to generate into ${generateAt}: it is not a directory. ` +
|
|
381
|
+
"`generateAt` must name a directory; choose another `generateAt`. Nothing was written.");
|
|
382
|
+
}
|
|
383
|
+
async function carriesOwnershipLine(fs, file) {
|
|
384
|
+
const handle = await fs.open(file, "r");
|
|
385
|
+
try {
|
|
386
|
+
const head = Buffer.alloc(GENERATED_OWNERSHIP_HEAD_BYTES);
|
|
387
|
+
const { bytesRead } = await handle.read(head, 0, GENERATED_OWNERSHIP_HEAD_BYTES, 0);
|
|
388
|
+
return carriesGeneratedOwnership(head.subarray(0, bytesRead).toString("utf8"));
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
await handle.close();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Undoes a replace that stopped part-way. When the undo itself fails, the
|
|
396
|
+
* sentence says where the previous `generated/` is: the next run puts it back.
|
|
397
|
+
*/
|
|
398
|
+
async function rollBack(paths, cause, undo) {
|
|
399
|
+
try {
|
|
400
|
+
await undo();
|
|
401
|
+
}
|
|
402
|
+
catch (rollbackError) {
|
|
403
|
+
throw new OutputWriteError("replace", `the generated output could not be moved into ${paths.generateAt} (${describe(cause)}), and the ` +
|
|
404
|
+
`previous output could not be restored (${describe(rollbackError)}). The previous ` +
|
|
405
|
+
`${GENERATED_DIRECTORY}/ is intact at ${paths.previous}; the next run puts it back.`, { cause });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Runs one stage, turning a filesystem error into that stage's sentence. An
|
|
410
|
+
* `OutputWriteError` passes through; anything without a `code` is a defect and
|
|
411
|
+
* keeps its stack.
|
|
412
|
+
*/
|
|
413
|
+
async function stage(name, paths, run) {
|
|
414
|
+
try {
|
|
415
|
+
return await run();
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
if (error instanceof OutputWriteError || !isSystemError(error)) {
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
throw new OutputWriteError(name, `could not ${STAGE_ACTIONS[name]} (${describe(error)}); ${LEFT_AS_THEY_WERE(paths)}`, { cause: error });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const STAGE_ACTIONS = {
|
|
425
|
+
prepare: "inspect the generated output",
|
|
426
|
+
temp: "write the generated output to a staging directory",
|
|
427
|
+
validate: "validate the generated output",
|
|
428
|
+
replace: "replace the generated output",
|
|
429
|
+
};
|
|
430
|
+
async function lstatOrUndefined(fs, file) {
|
|
431
|
+
try {
|
|
432
|
+
return await fs.lstat(file);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
if (isSystemError(error) && error.code === "ENOENT") {
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function isSystemError(error) {
|
|
442
|
+
return (error instanceof Error &&
|
|
443
|
+
typeof error.code === "string");
|
|
444
|
+
}
|
|
445
|
+
function describe(error) {
|
|
446
|
+
return isSystemError(error)
|
|
447
|
+
? `${error.code}: ${error.message}`
|
|
448
|
+
: String(error);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Whether `AvClient.ts` and `generated/` in `generateAt` hold exactly `tree` —
|
|
452
|
+
* every file byte for byte, no other file, and nothing a killed run moved aside —
|
|
453
|
+
* so that writing `tree` would change nothing (Phase 12-rest Q6: "up to date").
|
|
454
|
+
*/
|
|
455
|
+
export async function ownedOutputMatches(generateAt, tree) {
|
|
456
|
+
const root = path.resolve(generateAt);
|
|
457
|
+
if ((await lstatOrUndefined(fsPromises, path.join(root, PREVIOUS_GENERATED))) !== undefined) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
let present;
|
|
461
|
+
try {
|
|
462
|
+
present = (await fsPromises.readdir(path.join(root, GENERATED_DIRECTORY), {
|
|
463
|
+
recursive: true,
|
|
464
|
+
withFileTypes: true,
|
|
465
|
+
}))
|
|
466
|
+
.filter((entry) => !entry.isDirectory())
|
|
467
|
+
.map((entry) => path
|
|
468
|
+
.relative(root, path.join(entry.parentPath, entry.name))
|
|
469
|
+
.split(path.sep)
|
|
470
|
+
.join("/"));
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (isSystemError(error) && error.code === "ENOENT") {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
const expected = new Set(tree.map((file) => file.path));
|
|
479
|
+
if (present.length + 1 !== expected.size ||
|
|
480
|
+
present.some((file) => !expected.has(file))) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
for (const file of tree) {
|
|
484
|
+
let bytes;
|
|
485
|
+
try {
|
|
486
|
+
bytes = await fsPromises.readFile(path.join(root, file.path));
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
if (isSystemError(error) && error.code === "ENOENT") {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
if (!bytes.equals(Buffer.from(file.bytes))) {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return true;
|
|
499
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aventara/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.1.0-pilot.1",
|
|
4
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
5
|
+
"description": "Development-time generator for Aventara typed remote clients.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": "^22.18.0 || >=24.2.0"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"bin": {
|
|
13
|
+
"avclient": "./dist/avclient.bin.js"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"LICENSE-ADDITIONAL-PERMISSION.md"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@aventara/core": "0.1.0-pilot.1"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"typescript": ">=5.5.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"typescript": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@aventara/testing": "0.1.0-pilot.1",
|
|
41
|
+
"@types/node": "24.10.1",
|
|
42
|
+
"typescript": "^5.9.2"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
|
47
|
+
"test": "vitest run --typecheck --config ../../vitest.config.mts --root .",
|
|
48
|
+
"lint": "biome lint src"
|
|
49
|
+
}
|
|
6
50
|
}
|