@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,293 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ConfigError, type ConfigIssue } from "./errors.js";
|
|
5
|
+
import { manifestPathFor, type StatePaths } from "./paths.js";
|
|
6
|
+
import type { ResolvedConfigV2 } from "./resolve.js";
|
|
7
|
+
|
|
8
|
+
/** Highest `schemaVersion` this build of the portal understands. */
|
|
9
|
+
export const SUPPORTED_MANIFEST_SCHEMA = 1;
|
|
10
|
+
|
|
11
|
+
/** Object of booleans with camelCase names. A name that is absent means false (decision 19). */
|
|
12
|
+
const Capabilities = z.record(z.string(), z.boolean()).default({});
|
|
13
|
+
|
|
14
|
+
const Status = z.string().optional();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The service manifest, `services.json` (Reference, section 4.1). Every service owns its entry; a service that is
|
|
18
|
+
* missing from the manifest is disabled or not yet deployed. Unknown fields are kept so a newer deploy does not
|
|
19
|
+
* break an older reader.
|
|
20
|
+
*/
|
|
21
|
+
const ServiceManifestSchema = z.looseObject({
|
|
22
|
+
schemaVersion: z.number().int(),
|
|
23
|
+
generatedAt: z.string().optional(),
|
|
24
|
+
/** Set only on the stub written for local runs; never present in a deployed manifest. */
|
|
25
|
+
preview: z.boolean().optional(),
|
|
26
|
+
project: z.looseObject({ id: z.string().optional(), name: z.string() }),
|
|
27
|
+
services: z
|
|
28
|
+
.looseObject({
|
|
29
|
+
portal: z
|
|
30
|
+
.looseObject({
|
|
31
|
+
url: z.string().optional(),
|
|
32
|
+
docs: z
|
|
33
|
+
.looseObject({ provider: z.string(), url: z.string().optional() })
|
|
34
|
+
.optional(),
|
|
35
|
+
console: z
|
|
36
|
+
.looseObject({ links: z.record(z.string(), z.string()).optional() })
|
|
37
|
+
.optional(),
|
|
38
|
+
})
|
|
39
|
+
.optional(),
|
|
40
|
+
identity: z
|
|
41
|
+
.looseObject({
|
|
42
|
+
provider: z.string().default("apitogo"),
|
|
43
|
+
status: Status,
|
|
44
|
+
issuer: z.string().optional(),
|
|
45
|
+
clientId: z.string().optional(),
|
|
46
|
+
scopes: z.array(z.string()).optional(),
|
|
47
|
+
audience: z.string().optional(),
|
|
48
|
+
capabilities: Capabilities,
|
|
49
|
+
})
|
|
50
|
+
.optional(),
|
|
51
|
+
gateway: z
|
|
52
|
+
.looseObject({
|
|
53
|
+
provider: z.string().optional(),
|
|
54
|
+
status: Status,
|
|
55
|
+
baseUrl: z.string().optional(),
|
|
56
|
+
openapiUrl: z.string().optional(),
|
|
57
|
+
openapiHash: z.string().optional(),
|
|
58
|
+
audience: z.string().optional(),
|
|
59
|
+
keyHeaderName: z.string().optional(),
|
|
60
|
+
capabilities: Capabilities,
|
|
61
|
+
})
|
|
62
|
+
.optional(),
|
|
63
|
+
plans: z
|
|
64
|
+
.looseObject({
|
|
65
|
+
provider: z.string().optional(),
|
|
66
|
+
status: Status,
|
|
67
|
+
apiBaseUrl: z.string().optional(),
|
|
68
|
+
keysApiBaseUrl: z.string().optional(),
|
|
69
|
+
billing: z.string().optional(),
|
|
70
|
+
audience: z.string().optional(),
|
|
71
|
+
scopes: z.array(z.string()).optional(),
|
|
72
|
+
catalogRevision: z.number().int().optional(),
|
|
73
|
+
capabilities: Capabilities,
|
|
74
|
+
})
|
|
75
|
+
.optional(),
|
|
76
|
+
})
|
|
77
|
+
.default({}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export type ServiceManifest = z.infer<typeof ServiceManifestSchema>;
|
|
81
|
+
export type ManifestServiceName = "identity" | "gateway" | "plans" | "portal";
|
|
82
|
+
|
|
83
|
+
const issue = (
|
|
84
|
+
code: string,
|
|
85
|
+
path: string,
|
|
86
|
+
message: string,
|
|
87
|
+
hint?: string,
|
|
88
|
+
): ConfigIssue => ({ code, path, message, hint });
|
|
89
|
+
|
|
90
|
+
/** Validates parsed `services.json` content. Throws a ConfigError with stable codes. */
|
|
91
|
+
export function parseManifest(
|
|
92
|
+
raw: unknown,
|
|
93
|
+
file = "services.json",
|
|
94
|
+
): ServiceManifest {
|
|
95
|
+
const version =
|
|
96
|
+
typeof raw === "object" && raw !== null && "schemaVersion" in raw
|
|
97
|
+
? raw.schemaVersion
|
|
98
|
+
: undefined;
|
|
99
|
+
if (typeof version === "number" && version > SUPPORTED_MANIFEST_SCHEMA) {
|
|
100
|
+
throw new ConfigError(file, [
|
|
101
|
+
issue(
|
|
102
|
+
"PORTAL_MANIFEST_SCHEMA",
|
|
103
|
+
"schemaVersion",
|
|
104
|
+
`services.json has schemaVersion ${version} but this CLI supports ${SUPPORTED_MANIFEST_SCHEMA}.`,
|
|
105
|
+
"Upgrade @lukoweb/apitogo.",
|
|
106
|
+
),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
const result = ServiceManifestSchema.safeParse(raw);
|
|
110
|
+
if (!result.success) {
|
|
111
|
+
throw new ConfigError(
|
|
112
|
+
file,
|
|
113
|
+
result.error.issues.map((problem) =>
|
|
114
|
+
issue(
|
|
115
|
+
"PORTAL_MANIFEST_INVALID",
|
|
116
|
+
problem.path.join("."),
|
|
117
|
+
problem.message,
|
|
118
|
+
"Run `apitogo deploy` to write a fresh services.json.",
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return result.data;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Reads `services.json` from the state directory. Returns undefined when the file does not exist. */
|
|
127
|
+
export async function readManifest(
|
|
128
|
+
state: Pick<StatePaths, "dir">,
|
|
129
|
+
): Promise<ServiceManifest | undefined> {
|
|
130
|
+
const file = manifestPathFor(state);
|
|
131
|
+
let text: string;
|
|
132
|
+
try {
|
|
133
|
+
text = await readFile(file, "utf8");
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
return parseManifest(JSON.parse(text), file);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof SyntaxError) {
|
|
142
|
+
throw new ConfigError(file, [
|
|
143
|
+
issue(
|
|
144
|
+
"PORTAL_MANIFEST_INVALID",
|
|
145
|
+
"",
|
|
146
|
+
`is not valid JSON (${error.message}).`,
|
|
147
|
+
"Run `apitogo deploy` to write a fresh services.json.",
|
|
148
|
+
),
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type Entry = { status?: string; capabilities: Record<string, boolean> };
|
|
156
|
+
|
|
157
|
+
/** A service is usable when it has an entry and is not still provisioning. A missing status counts as ready. */
|
|
158
|
+
export function serviceReady(entry: { status?: string } | undefined): boolean {
|
|
159
|
+
return entry !== undefined && (entry.status ?? "ready") === "ready";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True only when the service exists, is ready and declares the capability. Absent means false. */
|
|
163
|
+
export function hasCapability(entry: Entry | undefined, name: string): boolean {
|
|
164
|
+
return serviceReady(entry) && entry?.capabilities[name] === true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Services the configured portal depends on. A production build needs a manifest entry for each of them
|
|
169
|
+
* (PORTAL_MANIFEST_MISSING); a docs-only portal with no gateway needs none.
|
|
170
|
+
*/
|
|
171
|
+
export function requiredServices(
|
|
172
|
+
resolved: ResolvedConfigV2,
|
|
173
|
+
): ManifestServiceName[] {
|
|
174
|
+
const portal = resolved.portal;
|
|
175
|
+
if (!portal?.enabled) return [];
|
|
176
|
+
const needs: ManifestServiceName[] = [];
|
|
177
|
+
const consoleOn = portal.console.enabled;
|
|
178
|
+
const gatewayOn = resolved.gateway?.enabled === true;
|
|
179
|
+
const identityOn = resolved.identity?.enabled === true;
|
|
180
|
+
if (identityOn && (consoleOn || portal.requireAuth)) needs.push("identity");
|
|
181
|
+
if (gatewayOn && (consoleOn || portal.docs.enabled)) needs.push("gateway");
|
|
182
|
+
if (resolved.plans?.enabled === true && consoleOn) needs.push("plans");
|
|
183
|
+
return needs;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Problems that stop a production build: a required service without a manifest entry. */
|
|
187
|
+
export function missingManifestServices(
|
|
188
|
+
resolved: ResolvedConfigV2,
|
|
189
|
+
manifest: ServiceManifest | undefined,
|
|
190
|
+
): ConfigIssue[] {
|
|
191
|
+
return requiredServices(resolved)
|
|
192
|
+
.filter((name) => manifest?.services[name] === undefined)
|
|
193
|
+
.map((name) =>
|
|
194
|
+
issue(
|
|
195
|
+
"PORTAL_MANIFEST_MISSING",
|
|
196
|
+
`services.${name}`,
|
|
197
|
+
`the portal needs the ${name} service, but services.json has no entry for it.`,
|
|
198
|
+
`Run \`apitogo deploy --only ${name}\` first.`,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The stand-in manifest for local runs before identity and plans exist. It is labelled `preview: true`, uses
|
|
205
|
+
* the demo issuer-less shape (no URLs are invented) and is refused by production builds.
|
|
206
|
+
*/
|
|
207
|
+
export function stubManifest(resolved: ResolvedConfigV2): ServiceManifest {
|
|
208
|
+
const portal = resolved.portal;
|
|
209
|
+
const consoleOn = portal?.enabled === true && portal.console.enabled;
|
|
210
|
+
return parseManifest({
|
|
211
|
+
schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
|
|
212
|
+
preview: true,
|
|
213
|
+
project: { id: resolved.project.id, name: resolved.project.name },
|
|
214
|
+
services: {
|
|
215
|
+
...(consoleOn && resolved.identity?.enabled
|
|
216
|
+
? {
|
|
217
|
+
identity: {
|
|
218
|
+
provider: "apitogo",
|
|
219
|
+
status: "ready",
|
|
220
|
+
capabilities: { nativeSignup: true },
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
: {}),
|
|
224
|
+
...(consoleOn && resolved.plans?.enabled
|
|
225
|
+
? {
|
|
226
|
+
plans: {
|
|
227
|
+
status: "ready",
|
|
228
|
+
capabilities: { catalog: true },
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
: {}),
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Adds what a portal deploy learned (project, public URL, docs provider) to the manifest. Entries of other services
|
|
238
|
+
* are kept as they are, so deploying the portal never erases what identity or plans wrote.
|
|
239
|
+
*/
|
|
240
|
+
export function withPortalEntry(
|
|
241
|
+
existing: ServiceManifest | undefined,
|
|
242
|
+
info: {
|
|
243
|
+
projectId: string;
|
|
244
|
+
projectName: string;
|
|
245
|
+
url: string;
|
|
246
|
+
docs: { provider: string; url?: string };
|
|
247
|
+
now?: Date;
|
|
248
|
+
},
|
|
249
|
+
): ServiceManifest {
|
|
250
|
+
const base: ServiceManifest =
|
|
251
|
+
existing ??
|
|
252
|
+
parseManifest({
|
|
253
|
+
schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
|
|
254
|
+
project: { name: info.projectName },
|
|
255
|
+
});
|
|
256
|
+
const previousPortal = base.services.portal;
|
|
257
|
+
return {
|
|
258
|
+
...base,
|
|
259
|
+
schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
|
|
260
|
+
generatedAt: (info.now ?? new Date()).toISOString(),
|
|
261
|
+
preview: undefined,
|
|
262
|
+
project: { ...base.project, id: info.projectId, name: info.projectName },
|
|
263
|
+
services: {
|
|
264
|
+
...base.services,
|
|
265
|
+
portal: {
|
|
266
|
+
...previousPortal,
|
|
267
|
+
url: info.url.replace(/\/+$/, ""),
|
|
268
|
+
docs: info.docs,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Replaces `services.json` atomically. Public values only: this file ships inside the portal. */
|
|
275
|
+
export async function writeManifest(
|
|
276
|
+
state: Pick<StatePaths, "dir">,
|
|
277
|
+
manifest: ServiceManifest,
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
const file = manifestPathFor(state);
|
|
280
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
281
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
282
|
+
try {
|
|
283
|
+
await writeFile(
|
|
284
|
+
temporary,
|
|
285
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
286
|
+
"utf8",
|
|
287
|
+
);
|
|
288
|
+
await rename(temporary, file);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
await rm(temporary, { force: true });
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ConfigError, type ConfigIssue } from "./errors.js";
|
|
3
|
+
import { CONFIG_VERSION, type ConfigV2, ConfigV2Schema } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
/** Keys that existed in an earlier design and were removed on purpose, with the reason to show the tenant. */
|
|
6
|
+
const REMOVED_KEYS: Readonly<Record<string, string>> = {
|
|
7
|
+
"plans.provider":
|
|
8
|
+
"plans.provider was removed. Only plans.billing.provider (stripe or none) is selectable.",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type PathSegment = PropertyKey;
|
|
12
|
+
|
|
13
|
+
function pathToString(path: readonly PathSegment[]): string {
|
|
14
|
+
return path.reduce<string>(
|
|
15
|
+
(acc, segment) =>
|
|
16
|
+
typeof segment === "number"
|
|
17
|
+
? `${acc}[${segment}]`
|
|
18
|
+
: acc === ""
|
|
19
|
+
? String(segment)
|
|
20
|
+
: `${acc}.${String(segment)}`,
|
|
21
|
+
"",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unwrapOptional(schema: z.core.$ZodType): z.core.$ZodType {
|
|
26
|
+
let current = schema;
|
|
27
|
+
while (current instanceof z.ZodOptional) current = current.unwrap();
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function step(
|
|
32
|
+
schema: z.core.$ZodType,
|
|
33
|
+
segment: PathSegment,
|
|
34
|
+
): z.core.$ZodType | undefined {
|
|
35
|
+
const current = unwrapOptional(schema);
|
|
36
|
+
if (current instanceof z.ZodObject && typeof segment === "string") {
|
|
37
|
+
return current.shape[segment];
|
|
38
|
+
}
|
|
39
|
+
if (current instanceof z.ZodArray && typeof segment === "number") {
|
|
40
|
+
return current.element;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The keys the schema accepts at `path`, used for "did you mean" hints. Empty when the path cannot be resolved. */
|
|
46
|
+
function knownKeysAt(path: readonly PathSegment[]): string[] {
|
|
47
|
+
let current: z.core.$ZodType | undefined = ConfigV2Schema;
|
|
48
|
+
for (const segment of path) {
|
|
49
|
+
if (!current) return [];
|
|
50
|
+
current = step(current, segment);
|
|
51
|
+
}
|
|
52
|
+
if (!current) return [];
|
|
53
|
+
const resolved = unwrapOptional(current);
|
|
54
|
+
return resolved instanceof z.ZodObject ? Object.keys(resolved.shape) : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function editDistance(a: string, b: string): number {
|
|
58
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
59
|
+
for (let i = 1; i <= a.length; i++) {
|
|
60
|
+
let previous = row[0] ?? 0;
|
|
61
|
+
row[0] = i;
|
|
62
|
+
for (let j = 1; j <= b.length; j++) {
|
|
63
|
+
const current = row[j] ?? 0;
|
|
64
|
+
row[j] = Math.min(
|
|
65
|
+
(row[j] ?? 0) + 1,
|
|
66
|
+
(row[j - 1] ?? 0) + 1,
|
|
67
|
+
previous + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
68
|
+
);
|
|
69
|
+
previous = current;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return row[b.length] ?? 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unknownKeyHint(
|
|
76
|
+
fullPath: string,
|
|
77
|
+
key: string,
|
|
78
|
+
known: readonly string[],
|
|
79
|
+
): string | undefined {
|
|
80
|
+
const removed = REMOVED_KEYS[fullPath];
|
|
81
|
+
if (removed) return removed;
|
|
82
|
+
|
|
83
|
+
const closest = known
|
|
84
|
+
.map((candidate) => ({
|
|
85
|
+
candidate,
|
|
86
|
+
distance: editDistance(key.toLowerCase(), candidate.toLowerCase()),
|
|
87
|
+
}))
|
|
88
|
+
.filter(({ distance }) => distance <= 2)
|
|
89
|
+
.sort((a, b) => a.distance - b.distance)[0];
|
|
90
|
+
if (closest) return `Did you mean "${closest.candidate}"?`;
|
|
91
|
+
return known.length > 0 ? `Allowed keys: ${known.join(", ")}.` : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mapIssue(issue: z.core.$ZodIssue): ConfigIssue[] {
|
|
95
|
+
const path = pathToString(issue.path);
|
|
96
|
+
|
|
97
|
+
switch (issue.code) {
|
|
98
|
+
case "unrecognized_keys":
|
|
99
|
+
return issue.keys.map((key) => {
|
|
100
|
+
const fullPath = pathToString([...issue.path, key]);
|
|
101
|
+
return {
|
|
102
|
+
code: "CONFIG_UNKNOWN_KEY",
|
|
103
|
+
path: fullPath,
|
|
104
|
+
message: `Unknown key "${key}".`,
|
|
105
|
+
hint: unknownKeyHint(fullPath, key, knownKeysAt(issue.path)),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
case "invalid_value":
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
code: "CONFIG_INVALID_VALUE",
|
|
112
|
+
path,
|
|
113
|
+
message: `must be one of: ${issue.values.map((v) => JSON.stringify(v)).join(", ")}.`,
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
case "custom": {
|
|
117
|
+
const code =
|
|
118
|
+
typeof issue.params?.code === "string"
|
|
119
|
+
? issue.params.code
|
|
120
|
+
: "CONFIG_INVALID_VALUE";
|
|
121
|
+
return [{ code, path, message: issue.message ?? "is invalid." }];
|
|
122
|
+
}
|
|
123
|
+
case "invalid_type":
|
|
124
|
+
return [
|
|
125
|
+
/received undefined/.test(issue.message)
|
|
126
|
+
? { code: "CONFIG_MISSING_FIELD", path, message: "is required." }
|
|
127
|
+
: { code: "CONFIG_INVALID_VALUE", path, message: issue.message },
|
|
128
|
+
];
|
|
129
|
+
default:
|
|
130
|
+
return [{ code: "CONFIG_INVALID_VALUE", path, message: issue.message }];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
135
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Checks that must run before the schema because the schema cannot express them well: the version gate and the
|
|
140
|
+
* one legacy shape that deserves its own message. Returns the issues, the value to validate, and whether to stop.
|
|
141
|
+
*/
|
|
142
|
+
function preflight(raw: unknown): {
|
|
143
|
+
issues: ConfigIssue[];
|
|
144
|
+
value: unknown;
|
|
145
|
+
stop: boolean;
|
|
146
|
+
} {
|
|
147
|
+
if (!isRecord(raw)) {
|
|
148
|
+
return {
|
|
149
|
+
issues: [
|
|
150
|
+
{
|
|
151
|
+
code: "CONFIG_INVALID_VALUE",
|
|
152
|
+
path: "",
|
|
153
|
+
message: "The config must be a JSON object.",
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
value: raw,
|
|
157
|
+
stop: true,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (raw.version !== CONFIG_VERSION) {
|
|
162
|
+
const found =
|
|
163
|
+
raw.version === undefined
|
|
164
|
+
? '"version" is missing.'
|
|
165
|
+
: `"version" is ${JSON.stringify(raw.version)}.`;
|
|
166
|
+
return {
|
|
167
|
+
issues: [
|
|
168
|
+
{
|
|
169
|
+
code: "CONFIG_VERSION_UNSUPPORTED",
|
|
170
|
+
path: "version",
|
|
171
|
+
message: `${found} This CLI reads config version ${CONFIG_VERSION}.`,
|
|
172
|
+
hint: `Set "version": ${CONFIG_VERSION}. Earlier flat configs are not supported; see the Tenant Service Model for the new shape.`,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
value: raw,
|
|
176
|
+
stop: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const issues: ConfigIssue[] = [];
|
|
181
|
+
const value = structuredClone(raw);
|
|
182
|
+
const portal = value.portal;
|
|
183
|
+
if (isRecord(portal) && Array.isArray(portal.docs)) {
|
|
184
|
+
issues.push({
|
|
185
|
+
code: "PORTAL_CONFIG_INVALID",
|
|
186
|
+
path: "portal.docs",
|
|
187
|
+
message:
|
|
188
|
+
"portal.docs is now an object. Rename the globs array to portal.docs.content.",
|
|
189
|
+
hint: 'For example: "docs": { "content": ["docs/**/*.md"] }',
|
|
190
|
+
});
|
|
191
|
+
// Reported above; keep the schema from reporting the same value again as a type error.
|
|
192
|
+
delete portal.docs;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { issues, value, stop: false };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Validates a parsed config. Throws a {@link ConfigError} listing every problem found. */
|
|
199
|
+
export function parseConfigV2(
|
|
200
|
+
raw: unknown,
|
|
201
|
+
file = "apitogo.config.json",
|
|
202
|
+
): ConfigV2 {
|
|
203
|
+
const pre = preflight(raw);
|
|
204
|
+
if (pre.stop) throw new ConfigError(file, pre.issues);
|
|
205
|
+
|
|
206
|
+
const result = ConfigV2Schema.safeParse(pre.value);
|
|
207
|
+
const issues = [
|
|
208
|
+
...pre.issues,
|
|
209
|
+
...(result.success ? [] : result.error.issues.flatMap(mapIssue)),
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
if (issues.length > 0 || !result.success) throw new ConfigError(file, issues);
|
|
213
|
+
return result.data;
|
|
214
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { Layout } from "./schema.js";
|
|
3
|
+
|
|
4
|
+
/** Names inside the generated-state directory (Tenant Service Model, section 4). All of it is regenerable. */
|
|
5
|
+
export const STATE_FILE = "state.json";
|
|
6
|
+
/** Service manifest written by `deploy` (Reference, section 4). Public values only. */
|
|
7
|
+
export const MANIFEST_FILE = "services.json";
|
|
8
|
+
export const GENERATED_DIR = "generated";
|
|
9
|
+
export const BUILD_DIR = "build";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Directory that holds the tenant-authored content for a layout: the config's own directory for `standalone`,
|
|
13
|
+
* an `apitogo/` folder next to it for `embedded`.
|
|
14
|
+
*/
|
|
15
|
+
export function layoutRoot(configDir: string, layout: Layout): string {
|
|
16
|
+
return layout === "embedded" ? path.join(configDir, "apitogo") : configDir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Directory for generated and observed state. It carries its own `.gitignore` containing `*`, so the tenant's root
|
|
21
|
+
* `.gitignore` never needs to change.
|
|
22
|
+
*/
|
|
23
|
+
export function stateDirFor(configDir: string, layout: Layout): string {
|
|
24
|
+
return layout === "embedded"
|
|
25
|
+
? path.join(configDir, "apitogo", ".state")
|
|
26
|
+
: path.join(configDir, ".apitogo");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Path of the service manifest inside a state directory. */
|
|
30
|
+
export const manifestPathFor = (state: { dir: string }): string =>
|
|
31
|
+
path.join(state.dir, MANIFEST_FILE);
|
|
32
|
+
|
|
33
|
+
export type StatePaths = {
|
|
34
|
+
dir: string;
|
|
35
|
+
/** Per-service ledger and artifact hashes. */
|
|
36
|
+
file: string;
|
|
37
|
+
/** Generated portal wrapper, resolved config snapshot, processed OpenAPI schemas. */
|
|
38
|
+
generatedDir: string;
|
|
39
|
+
/** Build output; replaces the old root `dist/`. */
|
|
40
|
+
buildDir: string;
|
|
41
|
+
/** The state directory's own ignore file. */
|
|
42
|
+
gitignore: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function statePathsFor(configDir: string, layout: Layout): StatePaths {
|
|
46
|
+
const dir = stateDirFor(configDir, layout);
|
|
47
|
+
return {
|
|
48
|
+
dir,
|
|
49
|
+
file: path.join(dir, STATE_FILE),
|
|
50
|
+
generatedDir: path.join(dir, GENERATED_DIR),
|
|
51
|
+
buildDir: path.join(dir, BUILD_DIR),
|
|
52
|
+
gitignore: path.join(dir, ".gitignore"),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ResolvedConfigV2 } from "./resolve.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the portal build has to do, derived from the resolved config. The generated wrapper (API-20) reads
|
|
5
|
+
* this and nothing else, so "docs only", "console only" and "both" are decided in one place.
|
|
6
|
+
*/
|
|
7
|
+
export type PortalProfile = {
|
|
8
|
+
/** False when `portal.enabled` is false or the config has no portal: nothing is built. */
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
docs: {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
provider: "apitogo" | "external";
|
|
13
|
+
/** Absolute globs of pages APItoGo builds. Empty unless APItoGo hosts the docs. */
|
|
14
|
+
content: string[];
|
|
15
|
+
/** Where an external docs site lives; console links point at it. */
|
|
16
|
+
url?: string;
|
|
17
|
+
};
|
|
18
|
+
console: { enabled: boolean };
|
|
19
|
+
/** Build pipelines the portal needs. A pipeline that is false must not run, not just stay hidden. */
|
|
20
|
+
pipelines: {
|
|
21
|
+
/** Markdown and MDX pages, and the navigation made from them. */
|
|
22
|
+
markdown: boolean;
|
|
23
|
+
/** OpenAPI processing and the API reference pages. */
|
|
24
|
+
openapi: boolean;
|
|
25
|
+
/** Search index; only for docs APItoGo hosts. */
|
|
26
|
+
search: "pagefind" | "inkeep" | "none";
|
|
27
|
+
};
|
|
28
|
+
/** Built modules for the portal, in the module system's terms (`modules.docs`, `modules.userPanel`, ...). */
|
|
29
|
+
modules: { docs: boolean; userPanel: boolean };
|
|
30
|
+
/** What a portal whose docs live elsewhere still guarantees, so an external docs site can link to it. */
|
|
31
|
+
externalDocs?: {
|
|
32
|
+
url?: string;
|
|
33
|
+
/** The OpenAPI files the docs site can fetch. */
|
|
34
|
+
openapiExport: boolean;
|
|
35
|
+
/** The console answers cross-origin requests from the docs site. */
|
|
36
|
+
cors: boolean;
|
|
37
|
+
/** The portal manifest lists console links (sign-in, keys, usage, pricing) for the docs site to use. */
|
|
38
|
+
consoleLinksInManifest: boolean;
|
|
39
|
+
/** The console carries the project's theme and name, not APItoGo's. */
|
|
40
|
+
whiteLabelConsole: boolean;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const NOT_BUILT: PortalProfile = {
|
|
45
|
+
enabled: false,
|
|
46
|
+
docs: { enabled: false, provider: "apitogo", content: [] },
|
|
47
|
+
console: { enabled: false },
|
|
48
|
+
pipelines: { markdown: false, openapi: false, search: "none" },
|
|
49
|
+
modules: { docs: false, userPanel: false },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function portalProfile(resolved: ResolvedConfigV2): PortalProfile {
|
|
53
|
+
const portal = resolved.portal;
|
|
54
|
+
if (!portal?.enabled) return NOT_BUILT;
|
|
55
|
+
|
|
56
|
+
const hostsDocs = portal.docs.enabled && portal.docs.provider === "apitogo";
|
|
57
|
+
const externalDocs =
|
|
58
|
+
portal.docs.enabled && portal.docs.provider === "external";
|
|
59
|
+
const gatewayOn = resolved.gateway?.enabled === true;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
enabled: true,
|
|
63
|
+
docs: {
|
|
64
|
+
enabled: portal.docs.enabled,
|
|
65
|
+
provider: portal.docs.provider,
|
|
66
|
+
content: hostsDocs ? portal.docs.content : [],
|
|
67
|
+
url: externalDocs ? portal.docs.url : undefined,
|
|
68
|
+
},
|
|
69
|
+
console: { enabled: portal.console.enabled },
|
|
70
|
+
pipelines: {
|
|
71
|
+
markdown: hostsDocs,
|
|
72
|
+
openapi: hostsDocs && gatewayOn,
|
|
73
|
+
search: hostsDocs ? portal.search : "none",
|
|
74
|
+
},
|
|
75
|
+
modules: { docs: hostsDocs, userPanel: portal.console.enabled },
|
|
76
|
+
...(externalDocs
|
|
77
|
+
? {
|
|
78
|
+
externalDocs: {
|
|
79
|
+
url: portal.docs.url,
|
|
80
|
+
openapiExport: gatewayOn,
|
|
81
|
+
cors: portal.console.enabled,
|
|
82
|
+
consoleLinksInManifest: portal.console.enabled,
|
|
83
|
+
whiteLabelConsole: portal.console.enabled,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
: {}),
|
|
87
|
+
};
|
|
88
|
+
}
|