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.
@@ -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
+ };