skur-manifest 0.1.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/dist/index.d.ts +96 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +144 -0
- package/dist/index.js.map +1 -0
- package/package.json +26 -0
- package/src/index.ts +222 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Manifest is an app's factory-facing contract: the only thing the factory
|
|
3
|
+
* needs to read in order to host an app. This module is deep and pure — it turns
|
|
4
|
+
* manifest *text* into a typed {@link Manifest} or a structured {@link ParseError},
|
|
5
|
+
* and never throws. Everything downstream (CLI scaffold, builder, orchestrator,
|
|
6
|
+
* proxy) reads the parsed structure, never the raw TOML.
|
|
7
|
+
*/
|
|
8
|
+
export interface ScheduledJob {
|
|
9
|
+
name: string;
|
|
10
|
+
cron: string;
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Manifest {
|
|
14
|
+
/** App slug — used as subdomain and as the key everywhere else. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** Human-readable name shown in the admin UI. */
|
|
17
|
+
display_name: string;
|
|
18
|
+
port: number;
|
|
19
|
+
db_name: string;
|
|
20
|
+
/** Names (not values) of the env vars the factory must inject before start. */
|
|
21
|
+
required_env: string[];
|
|
22
|
+
scheduled_jobs: ScheduledJob[];
|
|
23
|
+
/** Path prefixes that bypass SSO (webhook targets). */
|
|
24
|
+
public_paths: string[];
|
|
25
|
+
/** Auto-derived from `interAppEndpoint` exports at build time; empty at scaffold. */
|
|
26
|
+
inter_app_api: string[];
|
|
27
|
+
/** One-time seed for the access list. */
|
|
28
|
+
initial_admins: string[];
|
|
29
|
+
/** Where the factory's liveness probe hits the app. */
|
|
30
|
+
healthcheck_path: string;
|
|
31
|
+
/**
|
|
32
|
+
* Docker build context for this app. `"app"` (default) — the app's own folder
|
|
33
|
+
* is the context (self-contained: deps live only in that folder). `"repo"` —
|
|
34
|
+
* the apps-repo root is the context, which SDK apps need so npm workspaces
|
|
35
|
+
* resolve the pinned `skur-sdk`. The TypeScript scaffold sets `"repo"`.
|
|
36
|
+
*/
|
|
37
|
+
build: {
|
|
38
|
+
context: "app" | "repo";
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Shell command to run in a one-shot container of the NEW image before the
|
|
42
|
+
* version swap. The factory injects `DATABASE_URL` and the app's secrets.
|
|
43
|
+
* Non-zero exit aborts the swap — the old version keeps serving (ADR-0010).
|
|
44
|
+
* Absent means no migration step (e.g. a stateless app or schema-at-startup).
|
|
45
|
+
*/
|
|
46
|
+
migrate?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface ManifestIssue {
|
|
49
|
+
/** Dotted path to the offending field, e.g. `scheduled_jobs.0.cron`. */
|
|
50
|
+
path: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
export type ParseError = {
|
|
54
|
+
kind: "toml";
|
|
55
|
+
message: string;
|
|
56
|
+
} | {
|
|
57
|
+
kind: "schema";
|
|
58
|
+
message: string;
|
|
59
|
+
issues: ManifestIssue[];
|
|
60
|
+
};
|
|
61
|
+
export type ParseResult = {
|
|
62
|
+
ok: true;
|
|
63
|
+
manifest: Manifest;
|
|
64
|
+
} | {
|
|
65
|
+
ok: false;
|
|
66
|
+
error: ParseError;
|
|
67
|
+
};
|
|
68
|
+
/** Legal app slug: a valid subdomain label and Postgres-safe base. The single
|
|
69
|
+
* source of truth for "what is a valid app name" — the CLI scaffold imports it. */
|
|
70
|
+
export declare const APP_NAME: RegExp;
|
|
71
|
+
/**
|
|
72
|
+
* Write the registry-derived `inter_app_api` path list back into an app's manifest
|
|
73
|
+
* TOML, rewriting ONLY that one field and leaving the rest of the file
|
|
74
|
+
* byte-identical — comments, key order, whitespace, and every other field
|
|
75
|
+
* untouched. This is a surgical text edit, NOT a parse-and-re-serialize (which
|
|
76
|
+
* would clobber the comments and formatting an author put there).
|
|
77
|
+
*
|
|
78
|
+
* `inter_app_api` is auto-derived (the manifest must not drift from the code), so
|
|
79
|
+
* this is the build step's mechanism for persisting the derived list. If the field
|
|
80
|
+
* is absent it is appended; if present (inline or multi-line) its value is replaced
|
|
81
|
+
* in place.
|
|
82
|
+
*
|
|
83
|
+
* Pure: text + list in, new text out. It does not read or write files; the build
|
|
84
|
+
* step owns the I/O. It does not re-validate — the writer only ever emits a valid
|
|
85
|
+
* string array, and `Manifest.parse` remains the gate everything else reads through.
|
|
86
|
+
*/
|
|
87
|
+
export declare function writeInterAppApi(manifestText: string, paths: string[]): string;
|
|
88
|
+
export declare const Manifest: {
|
|
89
|
+
/**
|
|
90
|
+
* Parse manifest text. Returns `{ ok: true, manifest }` on success or
|
|
91
|
+
* `{ ok: false, error }` describing either a TOML syntax failure or a list of
|
|
92
|
+
* schema violations. Never throws.
|
|
93
|
+
*/
|
|
94
|
+
parse(text: string): ParseResult;
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AAEH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,QAAQ;IACvB,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,YAAY,EAAE,CAAC;IAC/B,uDAAuD;IACvD,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,qFAAqF;IACrF,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,yCAAyC;IACzC,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,uDAAuD;IACvD,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,KAAK,EAAE;QAAE,OAAO,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,CAAC;IACnC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,aAAa,EAAE,CAAA;CAAE,CAAC;AAEjE,MAAM,MAAM,WAAW,GACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,GAChC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAAC;AAErC;mFACmF;AACnF,eAAO,MAAM,QAAQ,QAAsB,CAAC;AAmF5C;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAwB9E;AAED,eAAO,MAAM,QAAQ;IACnB;;;;OAIG;gBACS,MAAM,GAAG,WAAW;CAuBjC,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { parse as parseToml, TomlError } from "smol-toml";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/** Legal app slug: a valid subdomain label and Postgres-safe base. The single
|
|
4
|
+
* source of truth for "what is a valid app name" — the CLI scaffold imports it. */
|
|
5
|
+
export const APP_NAME = /^[a-z][a-z0-9-]*$/;
|
|
6
|
+
const DB_NAME = /^[a-z][a-z0-9_]*$/;
|
|
7
|
+
const path = (field) => z.string().startsWith("/", `${field} must start with "/"`);
|
|
8
|
+
const scheduledJob = z
|
|
9
|
+
.object({
|
|
10
|
+
name: z.string().min(1),
|
|
11
|
+
cron: z.string().min(1),
|
|
12
|
+
path: path("scheduled job path"),
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
15
|
+
const manifestSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
name: z
|
|
18
|
+
.string()
|
|
19
|
+
.regex(APP_NAME, "name must be lowercase letters, digits, and hyphens"),
|
|
20
|
+
display_name: z.string().min(1),
|
|
21
|
+
port: z
|
|
22
|
+
.number()
|
|
23
|
+
.int("port must be an integer")
|
|
24
|
+
.gte(1, "port must be between 1 and 65535")
|
|
25
|
+
.lte(65535, "port must be between 1 and 65535"),
|
|
26
|
+
db_name: z
|
|
27
|
+
.string()
|
|
28
|
+
.regex(DB_NAME, "db_name must be a valid Postgres identifier"),
|
|
29
|
+
required_env: z.array(z.string().min(1)).default([]),
|
|
30
|
+
scheduled_jobs: z.array(scheduledJob).default([]),
|
|
31
|
+
public_paths: z.array(path("public path")).default([]),
|
|
32
|
+
inter_app_api: z
|
|
33
|
+
.array(path("inter-app path"))
|
|
34
|
+
.default([])
|
|
35
|
+
.superRefine((paths, ctx) => {
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
paths.forEach((p, i) => {
|
|
38
|
+
if (seen.has(p)) {
|
|
39
|
+
ctx.addIssue({
|
|
40
|
+
code: z.ZodIssueCode.custom,
|
|
41
|
+
path: [i],
|
|
42
|
+
message: `duplicate inter_app_api path "${p}"`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
seen.add(p);
|
|
46
|
+
});
|
|
47
|
+
}),
|
|
48
|
+
initial_admins: z.array(z.string().min(1)).default([]),
|
|
49
|
+
healthcheck_path: path("healthcheck_path").default("/healthz"),
|
|
50
|
+
build: z
|
|
51
|
+
.object({
|
|
52
|
+
context: z.enum(["app", "repo"]),
|
|
53
|
+
})
|
|
54
|
+
.strict()
|
|
55
|
+
.default({ context: "app" }),
|
|
56
|
+
migrate: z.string().min(1).optional(),
|
|
57
|
+
})
|
|
58
|
+
.strict();
|
|
59
|
+
function toIssues(error) {
|
|
60
|
+
return error.issues.map((issue) => ({
|
|
61
|
+
path: issue.path.join("."),
|
|
62
|
+
message: issue.message,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Render a TOML string array value, e.g. `["/a", "/b"]`. Single source of truth
|
|
67
|
+
* for how the field-writer formats the derived list (always inline — the lists are
|
|
68
|
+
* short, and inline keeps the surrounding file's shape stable).
|
|
69
|
+
*/
|
|
70
|
+
function renderStringArray(values) {
|
|
71
|
+
return `[${values.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Matches a top-level `inter_app_api = [ ... ]` assignment, inline OR multi-line.
|
|
75
|
+
* Anchored to a line start (after optional indentation) so a same-named key inside
|
|
76
|
+
* a `[[table]]` block or a comment can't be hit. `[\s\S]*?` spans newlines for the
|
|
77
|
+
* multi-line array form; non-greedy so it stops at the FIRST closing bracket.
|
|
78
|
+
*/
|
|
79
|
+
const INTER_APP_API_ASSIGNMENT = /^([ \t]*)inter_app_api[ \t]*=[ \t]*\[[\s\S]*?\]/m;
|
|
80
|
+
/**
|
|
81
|
+
* Write the registry-derived `inter_app_api` path list back into an app's manifest
|
|
82
|
+
* TOML, rewriting ONLY that one field and leaving the rest of the file
|
|
83
|
+
* byte-identical — comments, key order, whitespace, and every other field
|
|
84
|
+
* untouched. This is a surgical text edit, NOT a parse-and-re-serialize (which
|
|
85
|
+
* would clobber the comments and formatting an author put there).
|
|
86
|
+
*
|
|
87
|
+
* `inter_app_api` is auto-derived (the manifest must not drift from the code), so
|
|
88
|
+
* this is the build step's mechanism for persisting the derived list. If the field
|
|
89
|
+
* is absent it is appended; if present (inline or multi-line) its value is replaced
|
|
90
|
+
* in place.
|
|
91
|
+
*
|
|
92
|
+
* Pure: text + list in, new text out. It does not read or write files; the build
|
|
93
|
+
* step owns the I/O. It does not re-validate — the writer only ever emits a valid
|
|
94
|
+
* string array, and `Manifest.parse` remains the gate everything else reads through.
|
|
95
|
+
*/
|
|
96
|
+
export function writeInterAppApi(manifestText, paths) {
|
|
97
|
+
const rendered = `inter_app_api = ${renderStringArray(paths)}`;
|
|
98
|
+
if (INTER_APP_API_ASSIGNMENT.test(manifestText)) {
|
|
99
|
+
// Replace the existing assignment, preserving its original indentation.
|
|
100
|
+
return manifestText.replace(INTER_APP_API_ASSIGNMENT, (_match, indent) => `${indent}${rendered}`);
|
|
101
|
+
}
|
|
102
|
+
// Absent: append it before the first `[[table]]`/`[table]` section (top-level
|
|
103
|
+
// keys must precede tables in TOML), or at the end if there are none.
|
|
104
|
+
const tableHeader = /^[ \t]*\[/m;
|
|
105
|
+
const match = tableHeader.exec(manifestText);
|
|
106
|
+
if (match) {
|
|
107
|
+
const at = match.index;
|
|
108
|
+
const before = manifestText.slice(0, at).replace(/\n*$/, "");
|
|
109
|
+
const after = manifestText.slice(at);
|
|
110
|
+
return `${before}\n${rendered}\n\n${after}`;
|
|
111
|
+
}
|
|
112
|
+
const trimmed = manifestText.replace(/\n*$/, "");
|
|
113
|
+
return `${trimmed}\n${rendered}\n`;
|
|
114
|
+
}
|
|
115
|
+
export const Manifest = {
|
|
116
|
+
/**
|
|
117
|
+
* Parse manifest text. Returns `{ ok: true, manifest }` on success or
|
|
118
|
+
* `{ ok: false, error }` describing either a TOML syntax failure or a list of
|
|
119
|
+
* schema violations. Never throws.
|
|
120
|
+
*/
|
|
121
|
+
parse(text) {
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = parseToml(text);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
const message = err instanceof TomlError ? err.message : String(err);
|
|
128
|
+
return { ok: false, error: { kind: "toml", message } };
|
|
129
|
+
}
|
|
130
|
+
const result = manifestSchema.safeParse(raw);
|
|
131
|
+
if (!result.success) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: {
|
|
135
|
+
kind: "schema",
|
|
136
|
+
message: "manifest failed validation",
|
|
137
|
+
issues: toIssues(result.error),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, manifest: result.data };
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skur-manifest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Parses and validates an app's manifest file. Deep and pure: text in, typed Manifest or structured error out.",
|
|
6
|
+
"publishConfig":{"access": "public"},
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"source": "./src/index.ts",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.build.json"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"smol-toml": "^1.3.1",
|
|
24
|
+
"zod": "^3.24.1"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { parse as parseToml, TomlError } from "smol-toml";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Manifest is an app's factory-facing contract: the only thing the factory
|
|
6
|
+
* needs to read in order to host an app. This module is deep and pure — it turns
|
|
7
|
+
* manifest *text* into a typed {@link Manifest} or a structured {@link ParseError},
|
|
8
|
+
* and never throws. Everything downstream (CLI scaffold, builder, orchestrator,
|
|
9
|
+
* proxy) reads the parsed structure, never the raw TOML.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ScheduledJob {
|
|
13
|
+
name: string;
|
|
14
|
+
cron: string;
|
|
15
|
+
path: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Manifest {
|
|
19
|
+
/** App slug — used as subdomain and as the key everywhere else. */
|
|
20
|
+
name: string;
|
|
21
|
+
/** Human-readable name shown in the admin UI. */
|
|
22
|
+
display_name: string;
|
|
23
|
+
port: number;
|
|
24
|
+
db_name: string;
|
|
25
|
+
/** Names (not values) of the env vars the factory must inject before start. */
|
|
26
|
+
required_env: string[];
|
|
27
|
+
scheduled_jobs: ScheduledJob[];
|
|
28
|
+
/** Path prefixes that bypass SSO (webhook targets). */
|
|
29
|
+
public_paths: string[];
|
|
30
|
+
/** Auto-derived from `interAppEndpoint` exports at build time; empty at scaffold. */
|
|
31
|
+
inter_app_api: string[];
|
|
32
|
+
/** One-time seed for the access list. */
|
|
33
|
+
initial_admins: string[];
|
|
34
|
+
/** Where the factory's liveness probe hits the app. */
|
|
35
|
+
healthcheck_path: string;
|
|
36
|
+
/**
|
|
37
|
+
* Docker build context for this app. `"app"` (default) — the app's own folder
|
|
38
|
+
* is the context (self-contained: deps live only in that folder). `"repo"` —
|
|
39
|
+
* the apps-repo root is the context, which SDK apps need so npm workspaces
|
|
40
|
+
* resolve the pinned `skur-sdk`. The TypeScript scaffold sets `"repo"`.
|
|
41
|
+
*/
|
|
42
|
+
build: { context: "app" | "repo" };
|
|
43
|
+
/**
|
|
44
|
+
* Shell command to run in a one-shot container of the NEW image before the
|
|
45
|
+
* version swap. The factory injects `DATABASE_URL` and the app's secrets.
|
|
46
|
+
* Non-zero exit aborts the swap — the old version keeps serving (ADR-0010).
|
|
47
|
+
* Absent means no migration step (e.g. a stateless app or schema-at-startup).
|
|
48
|
+
*/
|
|
49
|
+
migrate?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ManifestIssue {
|
|
53
|
+
/** Dotted path to the offending field, e.g. `scheduled_jobs.0.cron`. */
|
|
54
|
+
path: string;
|
|
55
|
+
message: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ParseError =
|
|
59
|
+
| { kind: "toml"; message: string }
|
|
60
|
+
| { kind: "schema"; message: string; issues: ManifestIssue[] };
|
|
61
|
+
|
|
62
|
+
export type ParseResult =
|
|
63
|
+
| { ok: true; manifest: Manifest }
|
|
64
|
+
| { ok: false; error: ParseError };
|
|
65
|
+
|
|
66
|
+
/** Legal app slug: a valid subdomain label and Postgres-safe base. The single
|
|
67
|
+
* source of truth for "what is a valid app name" — the CLI scaffold imports it. */
|
|
68
|
+
export const APP_NAME = /^[a-z][a-z0-9-]*$/;
|
|
69
|
+
const DB_NAME = /^[a-z][a-z0-9_]*$/;
|
|
70
|
+
|
|
71
|
+
const path = (field: string) =>
|
|
72
|
+
z.string().startsWith("/", `${field} must start with "/"`);
|
|
73
|
+
|
|
74
|
+
const scheduledJob = z
|
|
75
|
+
.object({
|
|
76
|
+
name: z.string().min(1),
|
|
77
|
+
cron: z.string().min(1),
|
|
78
|
+
path: path("scheduled job path"),
|
|
79
|
+
})
|
|
80
|
+
.strict();
|
|
81
|
+
|
|
82
|
+
const manifestSchema = z
|
|
83
|
+
.object({
|
|
84
|
+
name: z
|
|
85
|
+
.string()
|
|
86
|
+
.regex(APP_NAME, "name must be lowercase letters, digits, and hyphens"),
|
|
87
|
+
display_name: z.string().min(1),
|
|
88
|
+
port: z
|
|
89
|
+
.number()
|
|
90
|
+
.int("port must be an integer")
|
|
91
|
+
.gte(1, "port must be between 1 and 65535")
|
|
92
|
+
.lte(65535, "port must be between 1 and 65535"),
|
|
93
|
+
db_name: z
|
|
94
|
+
.string()
|
|
95
|
+
.regex(DB_NAME, "db_name must be a valid Postgres identifier"),
|
|
96
|
+
required_env: z.array(z.string().min(1)).default([]),
|
|
97
|
+
scheduled_jobs: z.array(scheduledJob).default([]),
|
|
98
|
+
public_paths: z.array(path("public path")).default([]),
|
|
99
|
+
inter_app_api: z
|
|
100
|
+
.array(path("inter-app path"))
|
|
101
|
+
.default([])
|
|
102
|
+
.superRefine((paths, ctx) => {
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
paths.forEach((p, i) => {
|
|
105
|
+
if (seen.has(p)) {
|
|
106
|
+
ctx.addIssue({
|
|
107
|
+
code: z.ZodIssueCode.custom,
|
|
108
|
+
path: [i],
|
|
109
|
+
message: `duplicate inter_app_api path "${p}"`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
seen.add(p);
|
|
113
|
+
});
|
|
114
|
+
}),
|
|
115
|
+
initial_admins: z.array(z.string().min(1)).default([]),
|
|
116
|
+
healthcheck_path: path("healthcheck_path").default("/healthz"),
|
|
117
|
+
build: z
|
|
118
|
+
.object({
|
|
119
|
+
context: z.enum(["app", "repo"]),
|
|
120
|
+
})
|
|
121
|
+
.strict()
|
|
122
|
+
.default({ context: "app" }),
|
|
123
|
+
migrate: z.string().min(1).optional(),
|
|
124
|
+
})
|
|
125
|
+
.strict();
|
|
126
|
+
|
|
127
|
+
function toIssues(error: z.ZodError): ManifestIssue[] {
|
|
128
|
+
return error.issues.map((issue) => ({
|
|
129
|
+
path: issue.path.join("."),
|
|
130
|
+
message: issue.message,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Render a TOML string array value, e.g. `["/a", "/b"]`. Single source of truth
|
|
136
|
+
* for how the field-writer formats the derived list (always inline — the lists are
|
|
137
|
+
* short, and inline keeps the surrounding file's shape stable).
|
|
138
|
+
*/
|
|
139
|
+
function renderStringArray(values: string[]): string {
|
|
140
|
+
return `[${values.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Matches a top-level `inter_app_api = [ ... ]` assignment, inline OR multi-line.
|
|
145
|
+
* Anchored to a line start (after optional indentation) so a same-named key inside
|
|
146
|
+
* a `[[table]]` block or a comment can't be hit. `[\s\S]*?` spans newlines for the
|
|
147
|
+
* multi-line array form; non-greedy so it stops at the FIRST closing bracket.
|
|
148
|
+
*/
|
|
149
|
+
const INTER_APP_API_ASSIGNMENT = /^([ \t]*)inter_app_api[ \t]*=[ \t]*\[[\s\S]*?\]/m;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Write the registry-derived `inter_app_api` path list back into an app's manifest
|
|
153
|
+
* TOML, rewriting ONLY that one field and leaving the rest of the file
|
|
154
|
+
* byte-identical — comments, key order, whitespace, and every other field
|
|
155
|
+
* untouched. This is a surgical text edit, NOT a parse-and-re-serialize (which
|
|
156
|
+
* would clobber the comments and formatting an author put there).
|
|
157
|
+
*
|
|
158
|
+
* `inter_app_api` is auto-derived (the manifest must not drift from the code), so
|
|
159
|
+
* this is the build step's mechanism for persisting the derived list. If the field
|
|
160
|
+
* is absent it is appended; if present (inline or multi-line) its value is replaced
|
|
161
|
+
* in place.
|
|
162
|
+
*
|
|
163
|
+
* Pure: text + list in, new text out. It does not read or write files; the build
|
|
164
|
+
* step owns the I/O. It does not re-validate — the writer only ever emits a valid
|
|
165
|
+
* string array, and `Manifest.parse` remains the gate everything else reads through.
|
|
166
|
+
*/
|
|
167
|
+
export function writeInterAppApi(manifestText: string, paths: string[]): string {
|
|
168
|
+
const rendered = `inter_app_api = ${renderStringArray(paths)}`;
|
|
169
|
+
|
|
170
|
+
if (INTER_APP_API_ASSIGNMENT.test(manifestText)) {
|
|
171
|
+
// Replace the existing assignment, preserving its original indentation.
|
|
172
|
+
return manifestText.replace(
|
|
173
|
+
INTER_APP_API_ASSIGNMENT,
|
|
174
|
+
(_match, indent: string) => `${indent}${rendered}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Absent: append it before the first `[[table]]`/`[table]` section (top-level
|
|
179
|
+
// keys must precede tables in TOML), or at the end if there are none.
|
|
180
|
+
const tableHeader = /^[ \t]*\[/m;
|
|
181
|
+
const match = tableHeader.exec(manifestText);
|
|
182
|
+
if (match) {
|
|
183
|
+
const at = match.index;
|
|
184
|
+
const before = manifestText.slice(0, at).replace(/\n*$/, "");
|
|
185
|
+
const after = manifestText.slice(at);
|
|
186
|
+
return `${before}\n${rendered}\n\n${after}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const trimmed = manifestText.replace(/\n*$/, "");
|
|
190
|
+
return `${trimmed}\n${rendered}\n`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const Manifest = {
|
|
194
|
+
/**
|
|
195
|
+
* Parse manifest text. Returns `{ ok: true, manifest }` on success or
|
|
196
|
+
* `{ ok: false, error }` describing either a TOML syntax failure or a list of
|
|
197
|
+
* schema violations. Never throws.
|
|
198
|
+
*/
|
|
199
|
+
parse(text: string): ParseResult {
|
|
200
|
+
let raw: unknown;
|
|
201
|
+
try {
|
|
202
|
+
raw = parseToml(text);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
const message = err instanceof TomlError ? err.message : String(err);
|
|
205
|
+
return { ok: false, error: { kind: "toml", message } };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const result = manifestSchema.safeParse(raw);
|
|
209
|
+
if (!result.success) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: {
|
|
213
|
+
kind: "schema",
|
|
214
|
+
message: "manifest failed validation",
|
|
215
|
+
issues: toIssues(result.error),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { ok: true, manifest: result.data };
|
|
221
|
+
},
|
|
222
|
+
};
|