@lukoweb/apitogo 0.1.147 → 0.2.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/dist/cli/cli.js +5538 -1850
- package/dist/declarations/cli/common/constants.d.ts +0 -1
- package/dist/declarations/cli/platform/client/platform-api.d.ts +8 -0
- package/dist/declarations/cli/platform/config.d.ts +0 -10
- package/package.json +4 -1
- package/src/app/main.tsx +0 -1
- package/src/config/v2/__fixtures__/scenario-a-docs-only.json +6 -0
- package/src/config/v2/__fixtures__/scenario-b-managed-origin.json +14 -0
- package/src/config/v2/__fixtures__/scenario-c-proxy-oidc.json +15 -0
- package/src/config/v2/__fixtures__/scenario-h-full-stack.json +28 -0
- package/src/config/v2/errors.ts +42 -0
- package/src/config/v2/index.ts +105 -0
- package/src/config/v2/init.ts +273 -0
- package/src/config/v2/layout.ts +237 -0
- package/src/config/v2/ledger.ts +219 -0
- package/src/config/v2/load.ts +70 -0
- package/src/config/v2/manifest.ts +293 -0
- package/src/config/v2/parse.ts +214 -0
- package/src/config/v2/paths.ts +54 -0
- package/src/config/v2/portal-profile.ts +88 -0
- package/src/config/v2/resolve.ts +186 -0
- package/src/config/v2/rules.ts +650 -0
- package/src/config/v2/schema.ts +211 -0
- package/src/config/v2/services.ts +644 -0
- package/src/config/v2/workspace.ts +117 -0
- package/src/config/v2/wrapper.ts +397 -0
- package/src/vite/html.ts +0 -2
- package/src/vite/plugin-theme.ts +23 -7
- package/src/app/font.geist.css +0 -73
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { StatePaths } from "./paths.js";
|
|
5
|
+
import type { Layout } from "./schema.js";
|
|
6
|
+
|
|
7
|
+
export type LayoutDetection = { layout: Layout; reason: string };
|
|
8
|
+
|
|
9
|
+
/** Folders never searched for source files or docs: dependencies, build output, VCS and our own folders. */
|
|
10
|
+
const IGNORED_DIRS = new Set([
|
|
11
|
+
"node_modules",
|
|
12
|
+
".git",
|
|
13
|
+
"dist",
|
|
14
|
+
"build",
|
|
15
|
+
"bin",
|
|
16
|
+
"obj",
|
|
17
|
+
"out",
|
|
18
|
+
"coverage",
|
|
19
|
+
".next",
|
|
20
|
+
".turbo",
|
|
21
|
+
".cache",
|
|
22
|
+
".apitogo",
|
|
23
|
+
"apitogo",
|
|
24
|
+
"vendor",
|
|
25
|
+
"__pycache__",
|
|
26
|
+
".venv",
|
|
27
|
+
"venv",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** Manifests that identify a project (Reference, section 3.5). */
|
|
31
|
+
const PROJECT_MANIFESTS = new Set([
|
|
32
|
+
"package.json",
|
|
33
|
+
"go.mod",
|
|
34
|
+
"pyproject.toml",
|
|
35
|
+
"requirements.txt",
|
|
36
|
+
"Pipfile",
|
|
37
|
+
"pom.xml",
|
|
38
|
+
"build.gradle",
|
|
39
|
+
"build.gradle.kts",
|
|
40
|
+
"Gemfile",
|
|
41
|
+
"composer.json",
|
|
42
|
+
"Cargo.toml",
|
|
43
|
+
"mix.exs",
|
|
44
|
+
"deno.json",
|
|
45
|
+
]);
|
|
46
|
+
const PROJECT_MANIFEST_SUFFIXES = [".csproj", ".fsproj", ".sln"];
|
|
47
|
+
|
|
48
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
49
|
+
".ts",
|
|
50
|
+
".tsx",
|
|
51
|
+
".js",
|
|
52
|
+
".jsx",
|
|
53
|
+
".mjs",
|
|
54
|
+
".cjs",
|
|
55
|
+
".py",
|
|
56
|
+
".cs",
|
|
57
|
+
".go",
|
|
58
|
+
".java",
|
|
59
|
+
".kt",
|
|
60
|
+
".rb",
|
|
61
|
+
".php",
|
|
62
|
+
".rs",
|
|
63
|
+
".ex",
|
|
64
|
+
".swift",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** How deep, and how many entries, the source-file search may look. Detection is a hint, not a crawl. */
|
|
68
|
+
const SOURCE_SEARCH_DEPTH = 3;
|
|
69
|
+
const SOURCE_SEARCH_BUDGET = 3000;
|
|
70
|
+
|
|
71
|
+
async function readEntries(dir: string): Promise<Dirent[]> {
|
|
72
|
+
try {
|
|
73
|
+
return await readdir(dir, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const toPosix = (value: string) => value.split(path.sep).join("/");
|
|
80
|
+
|
|
81
|
+
async function findSourceFile(root: string): Promise<string | undefined> {
|
|
82
|
+
let budget = SOURCE_SEARCH_BUDGET;
|
|
83
|
+
|
|
84
|
+
async function search(
|
|
85
|
+
dir: string,
|
|
86
|
+
depth: number,
|
|
87
|
+
): Promise<string | undefined> {
|
|
88
|
+
const entries = await readEntries(dir);
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (budget-- <= 0) return undefined;
|
|
91
|
+
if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
92
|
+
return toPosix(path.relative(root, path.join(dir, entry.name)));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (depth >= SOURCE_SEARCH_DEPTH) return undefined;
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (!entry.isDirectory() || IGNORED_DIRS.has(entry.name)) continue;
|
|
98
|
+
const found = await search(path.join(dir, entry.name), depth + 1);
|
|
99
|
+
if (found) return found;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return search(root, 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Chooses the layout for `init` (Reference, section 3.5): an empty directory, or one with only VCS files, trivial
|
|
109
|
+
* files and documentation, is `standalone`; a recognisable project (a manifest or source files) is `embedded`.
|
|
110
|
+
*/
|
|
111
|
+
export async function detectLayout(dir: string): Promise<LayoutDetection> {
|
|
112
|
+
const entries = await readEntries(dir);
|
|
113
|
+
if (entries.length === 0) {
|
|
114
|
+
return { layout: "standalone", reason: "the directory is empty" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const manifest = entries.find(
|
|
118
|
+
(entry) =>
|
|
119
|
+
entry.isFile() &&
|
|
120
|
+
(PROJECT_MANIFESTS.has(entry.name) ||
|
|
121
|
+
PROJECT_MANIFEST_SUFFIXES.some((suffix) =>
|
|
122
|
+
entry.name.endsWith(suffix),
|
|
123
|
+
)),
|
|
124
|
+
);
|
|
125
|
+
if (manifest) {
|
|
126
|
+
return {
|
|
127
|
+
layout: "embedded",
|
|
128
|
+
reason: `it contains a project (${manifest.name})`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const source = await findSourceFile(dir);
|
|
133
|
+
if (source) {
|
|
134
|
+
return {
|
|
135
|
+
layout: "embedded",
|
|
136
|
+
reason: `it contains source code (${source})`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
layout: "standalone",
|
|
142
|
+
reason: "it holds only documentation and trivial files, no project",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type DocsCandidate = {
|
|
147
|
+
kind: "folder" | "file";
|
|
148
|
+
/** Path relative to the project directory, POSIX separators. */
|
|
149
|
+
path: string;
|
|
150
|
+
/** What goes into `portal.docs.content`: the docs are referenced in place, never moved or copied. */
|
|
151
|
+
glob: string;
|
|
152
|
+
fileCount: number;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const DOCS_FOLDER_NAMES = new Set(["docs", "doc", "documentation", "pages"]);
|
|
156
|
+
const DOCS_EXTENSIONS = new Set([".md", ".mdx"]);
|
|
157
|
+
/** Root markdown files that are project metadata, not documentation. */
|
|
158
|
+
const NOT_DOCS = /^(readme|license|licence|changelog)(\.|$)/i;
|
|
159
|
+
const DOCS_SEARCH_DEPTH = 2;
|
|
160
|
+
const OWN_PAGES_FOLDER = "portal/pages";
|
|
161
|
+
|
|
162
|
+
async function countDocs(dir: string): Promise<number> {
|
|
163
|
+
let count = 0;
|
|
164
|
+
for (const entry of await readEntries(dir)) {
|
|
165
|
+
if (entry.isDirectory()) {
|
|
166
|
+
if (!IGNORED_DIRS.has(entry.name))
|
|
167
|
+
count += await countDocs(path.join(dir, entry.name));
|
|
168
|
+
} else if (
|
|
169
|
+
entry.isFile() &&
|
|
170
|
+
DOCS_EXTENSIONS.has(path.extname(entry.name))
|
|
171
|
+
) {
|
|
172
|
+
count += 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return count;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Finds existing documentation: folders named `docs`, `doc`, `documentation` or `pages` (at the top or one level
|
|
180
|
+
* down) that hold `.md` or `.mdx` files, and loose markdown files at the top that are not README, LICENSE or
|
|
181
|
+
* CHANGELOG. Nothing is moved: each candidate becomes a glob.
|
|
182
|
+
*/
|
|
183
|
+
export async function detectExistingDocs(
|
|
184
|
+
dir: string,
|
|
185
|
+
): Promise<DocsCandidate[]> {
|
|
186
|
+
const candidates: DocsCandidate[] = [];
|
|
187
|
+
|
|
188
|
+
async function scan(current: string, depth: number) {
|
|
189
|
+
for (const entry of await readEntries(current)) {
|
|
190
|
+
const relative = toPosix(
|
|
191
|
+
path.relative(dir, path.join(current, entry.name)),
|
|
192
|
+
);
|
|
193
|
+
if (entry.isDirectory()) {
|
|
194
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
195
|
+
if (relative === OWN_PAGES_FOLDER) continue; // the portal's own default pages folder is already covered by the defaults
|
|
196
|
+
if (DOCS_FOLDER_NAMES.has(entry.name.toLowerCase())) {
|
|
197
|
+
const fileCount = await countDocs(path.join(current, entry.name));
|
|
198
|
+
if (fileCount > 0) {
|
|
199
|
+
candidates.push({
|
|
200
|
+
kind: "folder",
|
|
201
|
+
path: relative,
|
|
202
|
+
glob: `${relative}/**/*.{md,mdx}`,
|
|
203
|
+
fileCount,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} else if (depth < DOCS_SEARCH_DEPTH - 1) {
|
|
207
|
+
await scan(path.join(current, entry.name), depth + 1);
|
|
208
|
+
}
|
|
209
|
+
} else if (
|
|
210
|
+
depth === 0 &&
|
|
211
|
+
entry.isFile() &&
|
|
212
|
+
DOCS_EXTENSIONS.has(path.extname(entry.name)) &&
|
|
213
|
+
!NOT_DOCS.test(entry.name)
|
|
214
|
+
) {
|
|
215
|
+
candidates.push({
|
|
216
|
+
kind: "file",
|
|
217
|
+
path: relative,
|
|
218
|
+
glob: relative,
|
|
219
|
+
fileCount: 1,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await scan(dir, 0);
|
|
226
|
+
return candidates.sort((a, b) => a.path.localeCompare(b.path));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Creates the generated-state directory: itself, its own `.gitignore` containing `*`, and the `generated/` and
|
|
231
|
+
* `build/` folders. Never reads or edits the tenant's root `.gitignore`. Safe to call again.
|
|
232
|
+
*/
|
|
233
|
+
export async function ensureStateDir(state: StatePaths): Promise<void> {
|
|
234
|
+
await mkdir(state.generatedDir, { recursive: true });
|
|
235
|
+
await mkdir(state.buildDir, { recursive: true });
|
|
236
|
+
await writeFile(state.gitignore, "*\n");
|
|
237
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ApitogoError } from "../../lib/util/invariant.js";
|
|
4
|
+
import type { StatePaths } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
/** Version of the `state.json` format this build reads and writes. A newer file is refused, never overwritten. */
|
|
7
|
+
export const LEDGER_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
export type StepStatus = "running" | "succeeded" | "failed";
|
|
10
|
+
|
|
11
|
+
/** One step of one service, for example `portal` / `build`. */
|
|
12
|
+
export type StepRecord = {
|
|
13
|
+
status: StepStatus;
|
|
14
|
+
/** Hash of the inputs the step ran with. An equal hash means the result is still valid. */
|
|
15
|
+
hash?: string;
|
|
16
|
+
startedAt: string;
|
|
17
|
+
finishedAt?: string;
|
|
18
|
+
/** Why the step failed. */
|
|
19
|
+
error?: string;
|
|
20
|
+
/** Public facts the step produced, such as a deployed URL. */
|
|
21
|
+
output?: Record<string, string>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type Ledger = {
|
|
25
|
+
version: typeof LEDGER_VERSION;
|
|
26
|
+
services: Record<string, { steps: Record<string, StepRecord> }>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class LedgerError extends ApitogoError {
|
|
30
|
+
constructor(
|
|
31
|
+
public readonly code: "LEDGER_CORRUPT" | "LEDGER_VERSION_UNSUPPORTED",
|
|
32
|
+
message: string,
|
|
33
|
+
developerHint?: string,
|
|
34
|
+
) {
|
|
35
|
+
super(message, { developerHint, title: "Cannot read the project state" });
|
|
36
|
+
this.name = "LedgerError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const emptyLedger = (): Ledger => ({
|
|
41
|
+
version: LEDGER_VERSION,
|
|
42
|
+
services: {},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Reads `state.json`. A missing file is an empty ledger. A corrupt or newer file raises a {@link LedgerError}. */
|
|
46
|
+
export async function readLedger(
|
|
47
|
+
state: Pick<StatePaths, "file">,
|
|
48
|
+
): Promise<Ledger> {
|
|
49
|
+
let text: string;
|
|
50
|
+
try {
|
|
51
|
+
text = await readFile(state.file, "utf8");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
54
|
+
return emptyLedger();
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let raw: unknown;
|
|
59
|
+
try {
|
|
60
|
+
raw = JSON.parse(text);
|
|
61
|
+
} catch {
|
|
62
|
+
throw corrupt(state.file, "is not valid JSON");
|
|
63
|
+
}
|
|
64
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
65
|
+
throw corrupt(state.file, "is not an object");
|
|
66
|
+
}
|
|
67
|
+
const { version, services } = raw as Record<string, unknown>;
|
|
68
|
+
if (typeof version !== "number") throw corrupt(state.file, "has no version");
|
|
69
|
+
if (version > LEDGER_VERSION) {
|
|
70
|
+
throw new LedgerError(
|
|
71
|
+
"LEDGER_VERSION_UNSUPPORTED",
|
|
72
|
+
`${state.file} has version ${version} but this CLI supports ${LEDGER_VERSION}.`,
|
|
73
|
+
"Upgrade @lukoweb/apitogo.",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (version !== LEDGER_VERSION)
|
|
77
|
+
throw corrupt(state.file, `has unknown version ${version}`);
|
|
78
|
+
if (
|
|
79
|
+
typeof services !== "object" ||
|
|
80
|
+
services === null ||
|
|
81
|
+
Array.isArray(services)
|
|
82
|
+
) {
|
|
83
|
+
throw corrupt(state.file, "has no services object");
|
|
84
|
+
}
|
|
85
|
+
return { version: LEDGER_VERSION, services: services as Ledger["services"] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function corrupt(file: string, why: string): LedgerError {
|
|
89
|
+
return new LedgerError(
|
|
90
|
+
"LEDGER_CORRUPT",
|
|
91
|
+
`${file} ${why}.`,
|
|
92
|
+
"It only holds regenerable state: delete it and run the command again.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Replaces `state.json` atomically, so a crash never leaves a half-written file. */
|
|
97
|
+
export async function writeLedger(
|
|
98
|
+
state: Pick<StatePaths, "file">,
|
|
99
|
+
ledger: Ledger,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
await mkdir(path.dirname(state.file), { recursive: true });
|
|
102
|
+
const temporary = `${state.file}.${process.pid}.tmp`;
|
|
103
|
+
try {
|
|
104
|
+
await writeFile(temporary, `${JSON.stringify(ledger, null, 2)}\n`, "utf8");
|
|
105
|
+
await rename(temporary, state.file);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
await rm(temporary, { force: true });
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function stepOf(
|
|
113
|
+
ledger: Ledger,
|
|
114
|
+
service: string,
|
|
115
|
+
step: string,
|
|
116
|
+
): StepRecord | undefined {
|
|
117
|
+
return ledger.services[service]?.steps[step];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function withStep(
|
|
121
|
+
ledger: Ledger,
|
|
122
|
+
service: string,
|
|
123
|
+
step: string,
|
|
124
|
+
record: StepRecord,
|
|
125
|
+
): Ledger {
|
|
126
|
+
return {
|
|
127
|
+
...ledger,
|
|
128
|
+
services: {
|
|
129
|
+
...ledger.services,
|
|
130
|
+
[service]: {
|
|
131
|
+
steps: { ...ledger.services[service]?.steps, [step]: record },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type PipelineStep = {
|
|
138
|
+
name: string;
|
|
139
|
+
/** Hash of this step's inputs. */
|
|
140
|
+
hash: () => string | Promise<string>;
|
|
141
|
+
run: () => Promise<Record<string, string> | undefined | void>;
|
|
142
|
+
/** Extra proof the previous result still exists (for example the build folder). Defaults to true. */
|
|
143
|
+
stillValid?: () => Promise<boolean>;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type StepOutcome = {
|
|
147
|
+
step: string;
|
|
148
|
+
skipped: boolean;
|
|
149
|
+
output?: Record<string, string>;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Runs a service's steps in order against the ledger. A step is skipped when it already succeeded with the same
|
|
154
|
+
* hash and nothing before it ran again. A failure is recorded with its message and rethrown; the next run starts
|
|
155
|
+
* at that step, because the steps before it succeeded and are skipped.
|
|
156
|
+
*/
|
|
157
|
+
export async function runPipeline(
|
|
158
|
+
state: Pick<StatePaths, "file">,
|
|
159
|
+
service: string,
|
|
160
|
+
steps: readonly PipelineStep[],
|
|
161
|
+
options: { force?: boolean; now?: () => Date } = {},
|
|
162
|
+
): Promise<StepOutcome[]> {
|
|
163
|
+
const now = options.now ?? (() => new Date());
|
|
164
|
+
let ledger = await readLedger(state);
|
|
165
|
+
const outcomes: StepOutcome[] = [];
|
|
166
|
+
let upstreamRan = false;
|
|
167
|
+
|
|
168
|
+
for (const step of steps) {
|
|
169
|
+
const hash = await step.hash();
|
|
170
|
+
const previous = stepOf(ledger, service, step.name);
|
|
171
|
+
const reusable =
|
|
172
|
+
!options.force &&
|
|
173
|
+
!upstreamRan &&
|
|
174
|
+
previous?.status === "succeeded" &&
|
|
175
|
+
previous.hash === hash &&
|
|
176
|
+
(step.stillValid ? await step.stillValid() : true);
|
|
177
|
+
|
|
178
|
+
if (reusable) {
|
|
179
|
+
outcomes.push({
|
|
180
|
+
step: step.name,
|
|
181
|
+
skipped: true,
|
|
182
|
+
output: previous?.output,
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
upstreamRan = true;
|
|
188
|
+
const startedAt = now().toISOString();
|
|
189
|
+
ledger = withStep(ledger, service, step.name, {
|
|
190
|
+
status: "running",
|
|
191
|
+
hash,
|
|
192
|
+
startedAt,
|
|
193
|
+
});
|
|
194
|
+
await writeLedger(state, ledger);
|
|
195
|
+
try {
|
|
196
|
+
const output = (await step.run()) || undefined;
|
|
197
|
+
ledger = withStep(ledger, service, step.name, {
|
|
198
|
+
status: "succeeded",
|
|
199
|
+
hash,
|
|
200
|
+
startedAt,
|
|
201
|
+
finishedAt: now().toISOString(),
|
|
202
|
+
...(output ? { output } : {}),
|
|
203
|
+
});
|
|
204
|
+
await writeLedger(state, ledger);
|
|
205
|
+
outcomes.push({ step: step.name, skipped: false, output });
|
|
206
|
+
} catch (error) {
|
|
207
|
+
ledger = withStep(ledger, service, step.name, {
|
|
208
|
+
status: "failed",
|
|
209
|
+
hash,
|
|
210
|
+
startedAt,
|
|
211
|
+
finishedAt: now().toISOString(),
|
|
212
|
+
error: error instanceof Error ? error.message : String(error),
|
|
213
|
+
});
|
|
214
|
+
await writeLedger(state, ledger);
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return outcomes;
|
|
219
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { APITOGO_CONFIG_FILENAME } from "../apitogo-config-core.js";
|
|
4
|
+
import { ConfigError } from "./errors.js";
|
|
5
|
+
import { parseConfigV2 } from "./parse.js";
|
|
6
|
+
import { type ResolvedConfigV2, resolveConfigV2 } from "./resolve.js";
|
|
7
|
+
import type { ConfigV2 } from "./schema.js";
|
|
8
|
+
|
|
9
|
+
export type LoadedConfigV2 = {
|
|
10
|
+
config: ConfigV2;
|
|
11
|
+
resolved: ResolvedConfigV2;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** `JSON.parse` failures carry a position in different shapes across Node versions; turn it into line and column. */
|
|
15
|
+
function describeJsonError(error: unknown, text: string): string {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
const lineColumn = /line (\d+) column (\d+)/.exec(message);
|
|
18
|
+
if (lineColumn) return `${message}`;
|
|
19
|
+
|
|
20
|
+
const position = /position (\d+)/.exec(message);
|
|
21
|
+
if (!position) return message;
|
|
22
|
+
|
|
23
|
+
const offset = Number(position[1]);
|
|
24
|
+
const before = text.slice(0, offset);
|
|
25
|
+
const line = before.split("\n").length;
|
|
26
|
+
const column = offset - before.lastIndexOf("\n");
|
|
27
|
+
return `${message} (line ${line} column ${column})`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reads, validates and resolves a v2 config. `pathOrDir` is either the config file or the directory that
|
|
32
|
+
* contains `apitogo.config.json`. Invalid JSON, unknown keys, inline secrets and every other schema problem
|
|
33
|
+
* raise a {@link ConfigError}; nothing is silently dropped.
|
|
34
|
+
*/
|
|
35
|
+
export function loadConfigV2(pathOrDir: string): LoadedConfigV2 {
|
|
36
|
+
const resolvedPath = path.resolve(pathOrDir);
|
|
37
|
+
const configPath =
|
|
38
|
+
existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()
|
|
39
|
+
? path.join(resolvedPath, APITOGO_CONFIG_FILENAME)
|
|
40
|
+
: resolvedPath;
|
|
41
|
+
|
|
42
|
+
if (!existsSync(configPath)) {
|
|
43
|
+
throw new ConfigError(path.basename(configPath), [
|
|
44
|
+
{
|
|
45
|
+
code: "CONFIG_NOT_FOUND",
|
|
46
|
+
path: "",
|
|
47
|
+
message: `No config file at ${configPath}.`,
|
|
48
|
+
hint: "Run `apitogo init` to create one.",
|
|
49
|
+
},
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const text = readFileSync(configPath, "utf8");
|
|
54
|
+
let raw: unknown;
|
|
55
|
+
try {
|
|
56
|
+
raw = JSON.parse(text);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new ConfigError(path.basename(configPath), [
|
|
59
|
+
{
|
|
60
|
+
code: "CONFIG_INVALID_JSON",
|
|
61
|
+
path: "",
|
|
62
|
+
message: describeJsonError(error, text),
|
|
63
|
+
hint: "Fix the JSON syntax; comments and trailing commas are not allowed.",
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const config = parseConfigV2(raw, path.basename(configPath));
|
|
69
|
+
return { config, resolved: resolveConfigV2(config, configPath) };
|
|
70
|
+
}
|