@webstir-io/webstir-backend 0.1.15 → 0.1.16
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/README.md +106 -79
- package/dist/add.d.ts +59 -0
- package/dist/add.js +626 -0
- package/dist/build/artifacts.d.ts +115 -1
- package/dist/build/artifacts.js +4 -4
- package/dist/build/entries.js +1 -1
- package/dist/build/pipeline.d.ts +33 -1
- package/dist/build/pipeline.js +307 -65
- package/dist/cache/diff.js +9 -8
- package/dist/cache/reporters.js +1 -1
- package/dist/deploy-cli.d.ts +2 -0
- package/dist/deploy-cli.js +86 -0
- package/dist/diagnostics/summary.js +2 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/manifest/pipeline.js +103 -32
- package/dist/provider.js +35 -17
- package/dist/runtime/bun.d.ts +51 -0
- package/dist/runtime/bun.js +499 -0
- package/dist/runtime/core.d.ts +141 -0
- package/dist/runtime/core.js +316 -0
- package/dist/runtime/deploy-backend.d.ts +20 -0
- package/dist/runtime/deploy-backend.js +175 -0
- package/dist/runtime/deploy-shared.d.ts +43 -0
- package/dist/runtime/deploy-shared.js +75 -0
- package/dist/runtime/deploy-static.d.ts +2 -0
- package/dist/runtime/deploy-static.js +161 -0
- package/dist/runtime/deploy.d.ts +3 -0
- package/dist/runtime/deploy.js +91 -0
- package/dist/runtime/forms.d.ts +73 -0
- package/dist/runtime/forms.js +236 -0
- package/dist/runtime/request-hooks.d.ts +47 -0
- package/dist/runtime/request-hooks.js +102 -0
- package/dist/runtime/session-metadata.d.ts +13 -0
- package/dist/runtime/session-metadata.js +98 -0
- package/dist/runtime/session-runtime.d.ts +28 -0
- package/dist/runtime/session-runtime.js +180 -0
- package/dist/runtime/session.d.ts +83 -0
- package/dist/runtime/session.js +396 -0
- package/dist/runtime/views.d.ts +74 -0
- package/dist/runtime/views.js +221 -0
- package/dist/scaffold/assets.js +25 -21
- package/dist/testing/context.js +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +100 -56
- package/dist/utils/bun.d.ts +2 -0
- package/dist/utils/bun.js +13 -0
- package/dist/watch.d.ts +13 -1
- package/dist/watch.js +345 -97
- package/dist/workspace.d.ts +8 -0
- package/dist/workspace.js +44 -3
- package/package.json +49 -14
- package/scripts/publish.sh +2 -92
- package/scripts/smoke.mjs +282 -107
- package/scripts/update-contract.sh +12 -10
- package/src/add.ts +964 -0
- package/src/build/artifacts.ts +49 -46
- package/src/build/entries.ts +12 -12
- package/src/build/pipeline.ts +779 -403
- package/src/cache/diff.ts +111 -105
- package/src/cache/reporters.ts +26 -26
- package/src/deploy-cli.ts +111 -0
- package/src/diagnostics/summary.ts +28 -22
- package/src/index.ts +11 -0
- package/src/manifest/pipeline.ts +328 -215
- package/src/provider.ts +115 -98
- package/src/runtime/bun.ts +793 -0
- package/src/runtime/core.ts +598 -0
- package/src/runtime/deploy-backend.ts +239 -0
- package/src/runtime/deploy-shared.ts +136 -0
- package/src/runtime/deploy-static.ts +191 -0
- package/src/runtime/deploy.ts +143 -0
- package/src/runtime/forms.ts +364 -0
- package/src/runtime/request-hooks.ts +165 -0
- package/src/runtime/session-metadata.ts +135 -0
- package/src/runtime/session-runtime.ts +267 -0
- package/src/runtime/session.ts +642 -0
- package/src/runtime/views.ts +385 -0
- package/src/scaffold/assets.ts +77 -73
- package/src/testing/context.js +8 -9
- package/src/testing/context.ts +9 -9
- package/src/testing/index.d.ts +14 -3
- package/src/testing/index.js +254 -175
- package/src/testing/index.ts +298 -195
- package/src/testing/types.d.ts +18 -19
- package/src/testing/types.ts +18 -18
- package/src/utils/bun.ts +26 -0
- package/src/watch.ts +503 -99
- package/src/workspace.ts +59 -3
- package/templates/backend/.env.example +15 -0
- package/templates/backend/auth/adapter.ts +335 -36
- package/templates/backend/db/connection.ts +190 -65
- package/templates/backend/db/migrate.ts +149 -43
- package/templates/backend/db/types.d.ts +1 -1
- package/templates/backend/env.ts +132 -20
- package/templates/backend/functions/hello/index.ts +1 -2
- package/templates/backend/index.ts +15 -508
- package/templates/backend/jobs/nightly/index.ts +1 -1
- package/templates/backend/jobs/runtime.ts +24 -11
- package/templates/backend/jobs/scheduler.ts +208 -46
- package/templates/backend/module.ts +227 -13
- package/templates/backend/observability/logger.ts +2 -12
- package/templates/backend/observability/metrics.ts +8 -5
- package/templates/backend/session/sqlite.ts +152 -0
- package/templates/backend/session/store.ts +45 -0
- package/templates/backend/tsconfig.json +1 -1
- package/tests/add.test.js +327 -0
- package/tests/authAdapter.test.js +315 -0
- package/tests/bundlerParity.test.js +217 -0
- package/tests/cacheReporter.test.js +10 -10
- package/tests/dbConnection.test.js +209 -0
- package/tests/deploy.test.js +357 -0
- package/tests/envLoader.test.js +271 -17
- package/tests/integration.test.js +2432 -3
- package/tests/jobsScheduler.test.js +253 -0
- package/tests/manifest.test.js +287 -12
- package/tests/migrationRunner.test.js +249 -0
- package/tests/sessionScaffoldStore.test.js +752 -0
- package/tests/sessionStore.test.js +490 -0
- package/tests/testing.test.js +252 -0
- package/tests/watch.test.js +192 -32
- package/tsconfig.json +3 -10
- package/templates/backend/server/fastify.ts +0 -288
package/src/add.ts
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
FragmentUpdateMode,
|
|
7
|
+
HttpMethod,
|
|
8
|
+
JobDefinition,
|
|
9
|
+
RouteDefinition,
|
|
10
|
+
SchemaReference,
|
|
11
|
+
SessionAccessMode,
|
|
12
|
+
} from '@webstir-io/module-contract';
|
|
13
|
+
import { routeDefinitionSchema } from '@webstir-io/module-contract';
|
|
14
|
+
|
|
15
|
+
import { readTextFile, writeTextFile } from './utils/bun.js';
|
|
16
|
+
|
|
17
|
+
const ALLOWED_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as const;
|
|
18
|
+
const ALLOWED_SCHEMA_KINDS = ['zod', 'json-schema', 'ts-rest'] as const;
|
|
19
|
+
const ALLOWED_INTERACTIONS = ['navigation', 'mutation'] as const;
|
|
20
|
+
const ALLOWED_SESSION_MODES = ['optional', 'required'] as const;
|
|
21
|
+
const ALLOWED_FRAGMENT_MODES = ['replace', 'append', 'prepend'] as const;
|
|
22
|
+
const ALLOWED_SCHEDULE_MACROS = [
|
|
23
|
+
'yearly',
|
|
24
|
+
'annually',
|
|
25
|
+
'monthly',
|
|
26
|
+
'weekly',
|
|
27
|
+
'daily',
|
|
28
|
+
'midnight',
|
|
29
|
+
'hourly',
|
|
30
|
+
'reboot',
|
|
31
|
+
] as const;
|
|
32
|
+
const RATE_SCHEDULE_PATTERN = /^rate\((\d+)\s+(second|seconds|minute|minutes|hour|hours)\)$/i;
|
|
33
|
+
|
|
34
|
+
interface WorkspacePackageJson {
|
|
35
|
+
readonly webstir?: {
|
|
36
|
+
readonly moduleManifest?: {
|
|
37
|
+
readonly routes?: RouteDefinition[];
|
|
38
|
+
readonly jobs?: JobDefinition[];
|
|
39
|
+
readonly [key: string]: unknown;
|
|
40
|
+
};
|
|
41
|
+
readonly [key: string]: unknown;
|
|
42
|
+
};
|
|
43
|
+
readonly [key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type MutableWorkspacePackageJson = WorkspacePackageJson & {
|
|
47
|
+
webstir?: Record<string, unknown>;
|
|
48
|
+
[key: string]: unknown;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface AddRouteOptions {
|
|
52
|
+
readonly workspaceRoot: string;
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly method?: string;
|
|
55
|
+
readonly path?: string;
|
|
56
|
+
readonly summary?: string;
|
|
57
|
+
readonly description?: string;
|
|
58
|
+
readonly tags?: readonly string[];
|
|
59
|
+
readonly interaction?: string;
|
|
60
|
+
readonly sessionMode?: string;
|
|
61
|
+
readonly sessionWrite?: boolean;
|
|
62
|
+
readonly formUrlEncoded?: boolean;
|
|
63
|
+
readonly formCsrf?: boolean;
|
|
64
|
+
readonly fragmentTarget?: string;
|
|
65
|
+
readonly fragmentSelector?: string;
|
|
66
|
+
readonly fragmentMode?: string;
|
|
67
|
+
readonly paramsSchema?: string;
|
|
68
|
+
readonly querySchema?: string;
|
|
69
|
+
readonly bodySchema?: string;
|
|
70
|
+
readonly headersSchema?: string;
|
|
71
|
+
readonly responseSchema?: string;
|
|
72
|
+
readonly responseStatus?: string | number;
|
|
73
|
+
readonly responseHeadersSchema?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AddJobOptions {
|
|
77
|
+
readonly workspaceRoot: string;
|
|
78
|
+
readonly name: string;
|
|
79
|
+
readonly schedule?: string;
|
|
80
|
+
readonly description?: string;
|
|
81
|
+
readonly priority?: string | number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface UpdateRouteContractOptions {
|
|
85
|
+
readonly workspaceRoot: string;
|
|
86
|
+
readonly method: string;
|
|
87
|
+
readonly path: string;
|
|
88
|
+
readonly sessionMode?: SessionAccessMode | string | null;
|
|
89
|
+
readonly sessionWrite?: boolean | null;
|
|
90
|
+
readonly formUrlEncoded?: boolean | null;
|
|
91
|
+
readonly formCsrf?: boolean | null;
|
|
92
|
+
readonly fragmentTarget?: string | null;
|
|
93
|
+
readonly fragmentSelector?: string | null;
|
|
94
|
+
readonly fragmentMode?: FragmentUpdateMode | string | null;
|
|
95
|
+
readonly paramsSchema?: string | null;
|
|
96
|
+
readonly querySchema?: string | null;
|
|
97
|
+
readonly bodySchema?: string | null;
|
|
98
|
+
readonly headersSchema?: string | null;
|
|
99
|
+
readonly responseSchema?: string | null;
|
|
100
|
+
readonly responseStatus?: string | number | null;
|
|
101
|
+
readonly responseHeadersSchema?: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface BackendAddResult {
|
|
105
|
+
readonly subject: 'route' | 'job';
|
|
106
|
+
readonly target: string;
|
|
107
|
+
readonly changes: readonly string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function runAddRoute(options: AddRouteOptions): Promise<BackendAddResult> {
|
|
111
|
+
const name = normalizeRequiredName(options.name, 'route');
|
|
112
|
+
const method = normalizeMethod(options.method);
|
|
113
|
+
const routePath = normalizeRoutePath(options.path, name);
|
|
114
|
+
const summary = normalizeOptionalString(options.summary);
|
|
115
|
+
const description = normalizeOptionalString(options.description);
|
|
116
|
+
const tags = normalizeTags(options.tags);
|
|
117
|
+
const interaction = normalizeInteraction(options.interaction);
|
|
118
|
+
const session = buildRouteSession(options.sessionMode, options.sessionWrite);
|
|
119
|
+
const form = buildRouteForm(options.formUrlEncoded, options.formCsrf);
|
|
120
|
+
const fragment = buildRouteFragment(
|
|
121
|
+
options.fragmentTarget,
|
|
122
|
+
options.fragmentSelector,
|
|
123
|
+
options.fragmentMode,
|
|
124
|
+
);
|
|
125
|
+
const paramsSchema = parseSchemaReference(options.paramsSchema, '--params-schema');
|
|
126
|
+
const querySchema = parseSchemaReference(options.querySchema, '--query-schema');
|
|
127
|
+
const bodySchema = parseSchemaReference(options.bodySchema, '--body-schema');
|
|
128
|
+
const headersSchema = parseSchemaReference(options.headersSchema, '--headers-schema');
|
|
129
|
+
const responseSchema = parseSchemaReference(options.responseSchema, '--response-schema');
|
|
130
|
+
const responseHeadersSchema = parseSchemaReference(
|
|
131
|
+
options.responseHeadersSchema,
|
|
132
|
+
'--response-headers-schema',
|
|
133
|
+
);
|
|
134
|
+
const responseStatus = parseResponseStatus(options.responseStatus);
|
|
135
|
+
|
|
136
|
+
if ((responseHeadersSchema || responseStatus !== undefined) && !responseSchema) {
|
|
137
|
+
throw new Error('--response-schema is required when setting response headers or status.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const packageJsonPath = path.join(options.workspaceRoot, 'package.json');
|
|
141
|
+
const trackedPaths = [packageJsonPath];
|
|
142
|
+
const before = await captureFileState(trackedPaths);
|
|
143
|
+
|
|
144
|
+
const pkg = await readWorkspacePackageJson(packageJsonPath);
|
|
145
|
+
const webstir = asObject(pkg.webstir);
|
|
146
|
+
const moduleManifest = asObject(webstir.moduleManifest);
|
|
147
|
+
const routes = Array.isArray(moduleManifest.routes) ? [...moduleManifest.routes] : [];
|
|
148
|
+
const routeIndex = routes.findIndex(
|
|
149
|
+
(entry) => entry?.method === method && entry?.path === routePath,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const nextRoute: RouteDefinition = {
|
|
153
|
+
name,
|
|
154
|
+
method,
|
|
155
|
+
path: routePath,
|
|
156
|
+
...(summary ? { summary } : {}),
|
|
157
|
+
...(description ? { description } : {}),
|
|
158
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
159
|
+
...(interaction ? { interaction } : {}),
|
|
160
|
+
...(session ? { session } : {}),
|
|
161
|
+
...(form ? { form } : {}),
|
|
162
|
+
...(fragment ? { fragment } : {}),
|
|
163
|
+
...buildRouteInput(paramsSchema, querySchema, bodySchema, headersSchema),
|
|
164
|
+
...buildRouteOutput(responseSchema, responseHeadersSchema, responseStatus),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (routeIndex >= 0) {
|
|
168
|
+
routes[routeIndex] = nextRoute;
|
|
169
|
+
} else {
|
|
170
|
+
routes.push(nextRoute);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
moduleManifest.routes = routes;
|
|
174
|
+
webstir.moduleManifest = moduleManifest;
|
|
175
|
+
pkg.webstir = webstir;
|
|
176
|
+
await writeWorkspacePackageJson(packageJsonPath, pkg);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
subject: 'route',
|
|
180
|
+
target: `${method} ${routePath}`,
|
|
181
|
+
changes: await collectChangedFiles(options.workspaceRoot, trackedPaths, before),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function runAddJob(options: AddJobOptions): Promise<BackendAddResult> {
|
|
186
|
+
const name = normalizeRequiredName(options.name, 'job');
|
|
187
|
+
const schedule = normalizeOptionalString(options.schedule);
|
|
188
|
+
const description = normalizeOptionalString(options.description);
|
|
189
|
+
const priority = normalizeOptionalString(
|
|
190
|
+
options.priority !== undefined ? String(options.priority) : undefined,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (schedule) {
|
|
194
|
+
validateSchedule(schedule);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const packageJsonPath = path.join(options.workspaceRoot, 'package.json');
|
|
198
|
+
const jobDirectory = path.join(options.workspaceRoot, 'src', 'backend', 'jobs', name);
|
|
199
|
+
const jobFilePath = path.join(jobDirectory, 'index.ts');
|
|
200
|
+
const trackedPaths = [packageJsonPath, jobFilePath];
|
|
201
|
+
const before = await captureFileState(trackedPaths);
|
|
202
|
+
|
|
203
|
+
if (existsSync(jobDirectory)) {
|
|
204
|
+
throw new Error(`Job '${name}' already exists.`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await mkdir(jobDirectory, { recursive: true });
|
|
208
|
+
await writeTextFile(jobFilePath, buildJobTemplate(name));
|
|
209
|
+
|
|
210
|
+
const pkg = await readWorkspacePackageJson(packageJsonPath);
|
|
211
|
+
const webstir = asObject(pkg.webstir);
|
|
212
|
+
const moduleManifest = asObject(webstir.moduleManifest);
|
|
213
|
+
const jobs = Array.isArray(moduleManifest.jobs) ? [...moduleManifest.jobs] : [];
|
|
214
|
+
const jobIndex = jobs.findIndex((entry) => entry?.name === name);
|
|
215
|
+
|
|
216
|
+
const nextJob: JobDefinition & { description?: string } = {
|
|
217
|
+
name,
|
|
218
|
+
...(schedule ? { schedule } : {}),
|
|
219
|
+
...(description ? { description } : {}),
|
|
220
|
+
...parsePriority(priority),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (jobIndex >= 0) {
|
|
224
|
+
jobs[jobIndex] = nextJob;
|
|
225
|
+
} else {
|
|
226
|
+
jobs.push(nextJob);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
moduleManifest.jobs = jobs;
|
|
230
|
+
webstir.moduleManifest = moduleManifest;
|
|
231
|
+
pkg.webstir = webstir;
|
|
232
|
+
await writeWorkspacePackageJson(packageJsonPath, pkg);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
subject: 'job',
|
|
236
|
+
target: name,
|
|
237
|
+
changes: await collectChangedFiles(options.workspaceRoot, trackedPaths, before),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function runUpdateRouteContract(
|
|
242
|
+
options: UpdateRouteContractOptions,
|
|
243
|
+
): Promise<BackendAddResult> {
|
|
244
|
+
const packageJsonPath = path.join(options.workspaceRoot, 'package.json');
|
|
245
|
+
const trackedPaths = [packageJsonPath];
|
|
246
|
+
const before = await captureFileState(trackedPaths);
|
|
247
|
+
const pkg = await readWorkspacePackageJson(packageJsonPath);
|
|
248
|
+
const webstir = asObject(pkg.webstir);
|
|
249
|
+
const moduleManifest = asObject(webstir.moduleManifest);
|
|
250
|
+
const routes = Array.isArray(moduleManifest.routes) ? [...moduleManifest.routes] : [];
|
|
251
|
+
const method = normalizeMethod(options.method);
|
|
252
|
+
const routePath = normalizeExplicitRoutePath(options.path);
|
|
253
|
+
const routeIndex = routes.findIndex(
|
|
254
|
+
(entry) => entry?.method === method && entry?.path === routePath,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (routeIndex < 0) {
|
|
258
|
+
throw new Error(`Route '${method} ${routePath}' not found.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const existingRoute = routeDefinitionSchema.parse(routes[routeIndex]) as RouteDefinition;
|
|
262
|
+
const nextRoute = routeDefinitionSchema.parse({
|
|
263
|
+
...existingRoute,
|
|
264
|
+
...buildUpdatedRouteContract(existingRoute, options),
|
|
265
|
+
}) as RouteDefinition;
|
|
266
|
+
|
|
267
|
+
routes[routeIndex] = nextRoute;
|
|
268
|
+
moduleManifest.routes = routes;
|
|
269
|
+
webstir.moduleManifest = moduleManifest;
|
|
270
|
+
pkg.webstir = webstir;
|
|
271
|
+
await writeWorkspacePackageJson(packageJsonPath, pkg);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
subject: 'route',
|
|
275
|
+
target: `${method} ${routePath}`,
|
|
276
|
+
changes: await collectChangedFiles(options.workspaceRoot, trackedPaths, before),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function buildRouteInput(
|
|
281
|
+
paramsSchema?: SchemaReference,
|
|
282
|
+
querySchema?: SchemaReference,
|
|
283
|
+
bodySchema?: SchemaReference,
|
|
284
|
+
headersSchema?: SchemaReference,
|
|
285
|
+
): { input?: RouteDefinition['input'] } {
|
|
286
|
+
const input = {
|
|
287
|
+
...(paramsSchema ? { params: paramsSchema } : {}),
|
|
288
|
+
...(querySchema ? { query: querySchema } : {}),
|
|
289
|
+
...(bodySchema ? { body: bodySchema } : {}),
|
|
290
|
+
...(headersSchema ? { headers: headersSchema } : {}),
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return Object.keys(input).length > 0 ? { input } : {};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function buildRouteOutput(
|
|
297
|
+
responseSchema?: SchemaReference,
|
|
298
|
+
responseHeadersSchema?: SchemaReference,
|
|
299
|
+
responseStatus?: number,
|
|
300
|
+
): { output?: RouteDefinition['output'] } {
|
|
301
|
+
if (!responseSchema) {
|
|
302
|
+
return {};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
output: {
|
|
307
|
+
body: responseSchema,
|
|
308
|
+
...(responseStatus !== undefined ? { status: responseStatus } : {}),
|
|
309
|
+
...(responseHeadersSchema ? { headers: responseHeadersSchema } : {}),
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function buildUpdatedRouteContract(
|
|
315
|
+
existingRoute: RouteDefinition,
|
|
316
|
+
options: UpdateRouteContractOptions,
|
|
317
|
+
): Partial<RouteDefinition> {
|
|
318
|
+
const session = buildMergedRouteSession(existingRoute, options);
|
|
319
|
+
const form = buildMergedRouteForm(existingRoute, options);
|
|
320
|
+
const fragment = buildMergedRouteFragment(existingRoute, options);
|
|
321
|
+
const input = buildMergedRouteInput(existingRoute, options);
|
|
322
|
+
const output = buildMergedRouteOutput(existingRoute, options);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
...(session !== KEEP_VALUE ? { session } : {}),
|
|
326
|
+
...(form !== KEEP_VALUE ? { form } : {}),
|
|
327
|
+
...(fragment !== KEEP_VALUE ? { fragment } : {}),
|
|
328
|
+
...(input !== KEEP_VALUE ? { input } : {}),
|
|
329
|
+
...(output !== KEEP_VALUE ? { output } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const KEEP_VALUE = Symbol('keep-value');
|
|
334
|
+
|
|
335
|
+
function buildMergedRouteSession(
|
|
336
|
+
existingRoute: RouteDefinition,
|
|
337
|
+
options: UpdateRouteContractOptions,
|
|
338
|
+
): RouteDefinition['session'] | typeof KEEP_VALUE {
|
|
339
|
+
if (options.sessionMode === undefined && options.sessionWrite === undefined) {
|
|
340
|
+
return KEEP_VALUE;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const mode =
|
|
344
|
+
options.sessionMode === undefined
|
|
345
|
+
? existingRoute.session?.mode
|
|
346
|
+
: normalizeNullableSessionMode(options.sessionMode);
|
|
347
|
+
const write =
|
|
348
|
+
options.sessionWrite === undefined
|
|
349
|
+
? existingRoute.session?.write
|
|
350
|
+
: options.sessionWrite
|
|
351
|
+
? true
|
|
352
|
+
: undefined;
|
|
353
|
+
|
|
354
|
+
if (!mode && !write) {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
...(mode ? { mode } : {}),
|
|
360
|
+
...(write ? { write: true } : {}),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function buildMergedRouteForm(
|
|
365
|
+
existingRoute: RouteDefinition,
|
|
366
|
+
options: UpdateRouteContractOptions,
|
|
367
|
+
): RouteDefinition['form'] | typeof KEEP_VALUE {
|
|
368
|
+
if (options.formUrlEncoded === undefined && options.formCsrf === undefined) {
|
|
369
|
+
return KEEP_VALUE;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const existingForm = existingRoute.form;
|
|
373
|
+
const contentType =
|
|
374
|
+
options.formUrlEncoded === undefined
|
|
375
|
+
? existingForm?.contentType
|
|
376
|
+
: options.formUrlEncoded
|
|
377
|
+
? 'application/x-www-form-urlencoded'
|
|
378
|
+
: existingForm?.contentType === 'application/x-www-form-urlencoded'
|
|
379
|
+
? undefined
|
|
380
|
+
: existingForm?.contentType;
|
|
381
|
+
const csrf =
|
|
382
|
+
options.formCsrf === undefined ? existingForm?.csrf : options.formCsrf ? true : undefined;
|
|
383
|
+
const nextForm = {
|
|
384
|
+
...(existingForm ?? {}),
|
|
385
|
+
...(contentType ? { contentType } : {}),
|
|
386
|
+
...(csrf ? { csrf: true } : {}),
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
if (!contentType) {
|
|
390
|
+
delete nextForm.contentType;
|
|
391
|
+
}
|
|
392
|
+
if (!csrf) {
|
|
393
|
+
delete nextForm.csrf;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return Object.keys(nextForm).length > 0 ? nextForm : undefined;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildMergedRouteFragment(
|
|
400
|
+
existingRoute: RouteDefinition,
|
|
401
|
+
options: UpdateRouteContractOptions,
|
|
402
|
+
): RouteDefinition['fragment'] | typeof KEEP_VALUE {
|
|
403
|
+
if (
|
|
404
|
+
options.fragmentTarget === undefined &&
|
|
405
|
+
options.fragmentSelector === undefined &&
|
|
406
|
+
options.fragmentMode === undefined
|
|
407
|
+
) {
|
|
408
|
+
return KEEP_VALUE;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const target =
|
|
412
|
+
options.fragmentTarget === undefined
|
|
413
|
+
? existingRoute.fragment?.target
|
|
414
|
+
: (options.fragmentTarget ?? undefined);
|
|
415
|
+
const selector =
|
|
416
|
+
options.fragmentSelector === undefined
|
|
417
|
+
? existingRoute.fragment?.selector
|
|
418
|
+
: (options.fragmentSelector ?? undefined);
|
|
419
|
+
const mode =
|
|
420
|
+
options.fragmentMode === undefined
|
|
421
|
+
? existingRoute.fragment?.mode
|
|
422
|
+
: normalizeNullableFragmentMode(options.fragmentMode);
|
|
423
|
+
|
|
424
|
+
if (!target) {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
target,
|
|
430
|
+
...(selector ? { selector } : {}),
|
|
431
|
+
...(mode ? { mode } : {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function buildMergedRouteInput(
|
|
436
|
+
existingRoute: RouteDefinition,
|
|
437
|
+
options: UpdateRouteContractOptions,
|
|
438
|
+
): RouteDefinition['input'] | typeof KEEP_VALUE {
|
|
439
|
+
if (
|
|
440
|
+
options.paramsSchema === undefined &&
|
|
441
|
+
options.querySchema === undefined &&
|
|
442
|
+
options.bodySchema === undefined &&
|
|
443
|
+
options.headersSchema === undefined
|
|
444
|
+
) {
|
|
445
|
+
return KEEP_VALUE;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const params = mergeSchemaReference(
|
|
449
|
+
existingRoute.input?.params,
|
|
450
|
+
options.paramsSchema,
|
|
451
|
+
'--params-schema',
|
|
452
|
+
);
|
|
453
|
+
const query = mergeSchemaReference(
|
|
454
|
+
existingRoute.input?.query,
|
|
455
|
+
options.querySchema,
|
|
456
|
+
'--query-schema',
|
|
457
|
+
);
|
|
458
|
+
const body = mergeSchemaReference(existingRoute.input?.body, options.bodySchema, '--body-schema');
|
|
459
|
+
const headers = mergeSchemaReference(
|
|
460
|
+
existingRoute.input?.headers,
|
|
461
|
+
options.headersSchema,
|
|
462
|
+
'--headers-schema',
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
return buildInputObject({ params, query, body, headers });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function buildMergedRouteOutput(
|
|
469
|
+
existingRoute: RouteDefinition,
|
|
470
|
+
options: UpdateRouteContractOptions,
|
|
471
|
+
): RouteDefinition['output'] | typeof KEEP_VALUE {
|
|
472
|
+
if (
|
|
473
|
+
options.responseSchema === undefined &&
|
|
474
|
+
options.responseStatus === undefined &&
|
|
475
|
+
options.responseHeadersSchema === undefined
|
|
476
|
+
) {
|
|
477
|
+
return KEEP_VALUE;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (existingRoute.output && 'responses' in existingRoute.output) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`Route '${existingRoute.method} ${existingRoute.path}' uses response variants and cannot be updated with update_route_contract.`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
if (existingRoute.output && 'redirect' in existingRoute.output) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`Route '${existingRoute.method} ${existingRoute.path}' uses redirect output and cannot be updated with update_route_contract.`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const existingOutput =
|
|
492
|
+
existingRoute.output && 'body' in existingRoute.output ? existingRoute.output : undefined;
|
|
493
|
+
const body = mergeSchemaReference(
|
|
494
|
+
existingOutput?.body,
|
|
495
|
+
options.responseSchema,
|
|
496
|
+
'--response-schema',
|
|
497
|
+
);
|
|
498
|
+
const headers = mergeSchemaReference(
|
|
499
|
+
existingOutput?.headers,
|
|
500
|
+
options.responseHeadersSchema,
|
|
501
|
+
'--response-headers-schema',
|
|
502
|
+
);
|
|
503
|
+
const status =
|
|
504
|
+
options.responseStatus === undefined
|
|
505
|
+
? existingOutput?.status
|
|
506
|
+
: options.responseStatus === null
|
|
507
|
+
? undefined
|
|
508
|
+
: parseResponseStatus(options.responseStatus);
|
|
509
|
+
|
|
510
|
+
if ((headers || status !== undefined) && !body) {
|
|
511
|
+
throw new Error('--response-schema is required when setting response headers or status.');
|
|
512
|
+
}
|
|
513
|
+
if (!body) {
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
...(status !== undefined ? { status } : {}),
|
|
519
|
+
...(headers ? { headers } : {}),
|
|
520
|
+
body,
|
|
521
|
+
...(existingOutput && 'fragment' in existingOutput
|
|
522
|
+
? { fragment: existingOutput.fragment }
|
|
523
|
+
: {}),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function mergeSchemaReference(
|
|
528
|
+
existing: SchemaReference | undefined,
|
|
529
|
+
next: string | null | undefined,
|
|
530
|
+
flag: string,
|
|
531
|
+
): SchemaReference | undefined {
|
|
532
|
+
if (next === undefined) {
|
|
533
|
+
return existing;
|
|
534
|
+
}
|
|
535
|
+
if (next === null) {
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
return parseSchemaReference(next, flag);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function buildInputObject(input: {
|
|
542
|
+
readonly params?: SchemaReference;
|
|
543
|
+
readonly query?: SchemaReference;
|
|
544
|
+
readonly body?: SchemaReference;
|
|
545
|
+
readonly headers?: SchemaReference;
|
|
546
|
+
}): RouteDefinition['input'] {
|
|
547
|
+
const next = {
|
|
548
|
+
...(input.params ? { params: input.params } : {}),
|
|
549
|
+
...(input.query ? { query: input.query } : {}),
|
|
550
|
+
...(input.body ? { body: input.body } : {}),
|
|
551
|
+
...(input.headers ? { headers: input.headers } : {}),
|
|
552
|
+
};
|
|
553
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function normalizeInteraction(value?: string): RouteDefinition['interaction'] | undefined {
|
|
557
|
+
const normalized = normalizeOptionalString(value);
|
|
558
|
+
if (!normalized) {
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (normalized === 'navigation' || normalized === 'mutation') {
|
|
563
|
+
return normalized;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
throw new Error(
|
|
567
|
+
`Invalid --interaction value '${normalized}'. Allowed values: ${ALLOWED_INTERACTIONS.join(', ')}.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function buildRouteSession(mode?: string, write?: boolean): RouteDefinition['session'] | undefined {
|
|
572
|
+
const normalizedMode = normalizeSessionMode(mode);
|
|
573
|
+
if (!normalizedMode && !write) {
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
...(normalizedMode ? { mode: normalizedMode } : {}),
|
|
579
|
+
...(write ? { write: true } : {}),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function normalizeSessionMode(value?: string): SessionAccessMode | undefined {
|
|
584
|
+
const normalized = normalizeOptionalString(value);
|
|
585
|
+
if (!normalized) {
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (normalized === 'optional' || normalized === 'required') {
|
|
590
|
+
return normalized;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
throw new Error(
|
|
594
|
+
`Invalid --session value '${normalized}'. Allowed values: ${ALLOWED_SESSION_MODES.join(', ')}.`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function buildRouteForm(urlEncoded?: boolean, csrf?: boolean): RouteDefinition['form'] | undefined {
|
|
599
|
+
if (!urlEncoded && !csrf) {
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
...(urlEncoded ? { contentType: 'application/x-www-form-urlencoded' } : {}),
|
|
605
|
+
...(csrf ? { csrf: true } : {}),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function buildRouteFragment(
|
|
610
|
+
target?: string,
|
|
611
|
+
selector?: string,
|
|
612
|
+
mode?: string,
|
|
613
|
+
): RouteDefinition['fragment'] | undefined {
|
|
614
|
+
const normalizedTarget = normalizeOptionalString(target);
|
|
615
|
+
const normalizedSelector = normalizeOptionalString(selector);
|
|
616
|
+
const normalizedMode = normalizeFragmentMode(mode);
|
|
617
|
+
|
|
618
|
+
if (!normalizedTarget && !normalizedSelector && !normalizedMode) {
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (!normalizedTarget) {
|
|
623
|
+
throw new Error('--fragment-target is required when setting fragment metadata.');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
target: normalizedTarget,
|
|
628
|
+
...(normalizedSelector ? { selector: normalizedSelector } : {}),
|
|
629
|
+
...(normalizedMode ? { mode: normalizedMode } : {}),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function normalizeFragmentMode(value?: string): FragmentUpdateMode | undefined {
|
|
634
|
+
const normalized = normalizeOptionalString(value);
|
|
635
|
+
if (!normalized) {
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (normalized === 'replace' || normalized === 'append' || normalized === 'prepend') {
|
|
640
|
+
return normalized;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
throw new Error(
|
|
644
|
+
`Invalid --fragment-mode value '${normalized}'. Allowed values: ${ALLOWED_FRAGMENT_MODES.join(', ')}.`,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function normalizeNullableSessionMode(
|
|
649
|
+
value: UpdateRouteContractOptions['sessionMode'],
|
|
650
|
+
): SessionAccessMode | undefined {
|
|
651
|
+
if (value === null || value === undefined) {
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
if (value === 'optional' || value === 'required') {
|
|
655
|
+
return value;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
throw new Error(
|
|
659
|
+
`Invalid --session value '${value}'. Allowed values: ${ALLOWED_SESSION_MODES.join(', ')}.`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function normalizeNullableFragmentMode(
|
|
664
|
+
value: UpdateRouteContractOptions['fragmentMode'],
|
|
665
|
+
): FragmentUpdateMode | undefined {
|
|
666
|
+
if (value === null || value === undefined) {
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
if (value === 'replace' || value === 'append' || value === 'prepend') {
|
|
670
|
+
return value;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
throw new Error(
|
|
674
|
+
`Invalid --fragment-mode value '${value}'. Allowed values: ${ALLOWED_FRAGMENT_MODES.join(', ')}.`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function buildJobTemplate(name: string): string {
|
|
679
|
+
return `// Generated by webstir add-job
|
|
680
|
+
export async function run(): Promise<void> {
|
|
681
|
+
console.info('[job:${name}] ran at', new Date().toISOString());
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Execute when launched directly: \`bun build/backend/jobs/${name}/index.js\`
|
|
685
|
+
const isMain = (() => {
|
|
686
|
+
try {
|
|
687
|
+
const argv1 = process.argv?.[1];
|
|
688
|
+
if (!argv1) return false;
|
|
689
|
+
const here = new URL(import.meta.url);
|
|
690
|
+
const run = new URL(\`file://\${argv1}\`);
|
|
691
|
+
return here.pathname === run.pathname;
|
|
692
|
+
} catch {
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
})();
|
|
696
|
+
|
|
697
|
+
if (isMain) {
|
|
698
|
+
run().catch((err) => {
|
|
699
|
+
console.error(err);
|
|
700
|
+
process.exitCode = 1;
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function normalizeRequiredName(name: string, subject: 'route' | 'job'): string {
|
|
707
|
+
const normalized = name.trim();
|
|
708
|
+
if (!normalized) {
|
|
709
|
+
throw new Error(`Missing ${subject} name.`);
|
|
710
|
+
}
|
|
711
|
+
return normalized;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function normalizeMethod(value?: string): HttpMethod {
|
|
715
|
+
const candidate = (value ?? 'GET').trim().toUpperCase();
|
|
716
|
+
if (!ALLOWED_METHODS.includes(candidate as HttpMethod)) {
|
|
717
|
+
throw new Error(
|
|
718
|
+
`Invalid --method value '${candidate}'. Allowed values: ${ALLOWED_METHODS.join(', ')}.`,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
return candidate as HttpMethod;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function normalizeRoutePath(value: string | undefined, name: string): string {
|
|
725
|
+
const routePath = value?.trim() || `/api/${name}`;
|
|
726
|
+
return routePath.startsWith('/') ? routePath : `/${routePath}`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function normalizeExplicitRoutePath(value: string): string {
|
|
730
|
+
const routePath = value.trim();
|
|
731
|
+
if (!routePath) {
|
|
732
|
+
throw new Error('Missing route path.');
|
|
733
|
+
}
|
|
734
|
+
return routePath.startsWith('/') ? routePath : `/${routePath}`;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function normalizeOptionalString(value?: string): string | undefined {
|
|
738
|
+
const normalized = value?.trim();
|
|
739
|
+
return normalized ? normalized : undefined;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function normalizeTags(tags: readonly string[] | undefined): string[] {
|
|
743
|
+
if (!tags || tags.length === 0) {
|
|
744
|
+
return [];
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const seen = new Set<string>();
|
|
748
|
+
const normalized: string[] = [];
|
|
749
|
+
for (const tag of tags) {
|
|
750
|
+
const candidate = tag.trim();
|
|
751
|
+
if (!candidate) {
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const key = candidate.toLowerCase();
|
|
756
|
+
if (seen.has(key)) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
seen.add(key);
|
|
761
|
+
normalized.push(candidate);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return normalized;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function parseSchemaReference(
|
|
768
|
+
value: string | undefined,
|
|
769
|
+
flag: string,
|
|
770
|
+
): SchemaReference | undefined {
|
|
771
|
+
if (!value) {
|
|
772
|
+
return undefined;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const trimmed = value.trim();
|
|
776
|
+
let kind = 'zod';
|
|
777
|
+
let remainder = trimmed;
|
|
778
|
+
const colonIndex = trimmed.indexOf(':');
|
|
779
|
+
if (colonIndex >= 0) {
|
|
780
|
+
if (colonIndex === 0) {
|
|
781
|
+
throw new Error(`Invalid ${flag} value '${value}'. Missing schema name.`);
|
|
782
|
+
}
|
|
783
|
+
kind = trimmed.slice(0, colonIndex).trim().toLowerCase();
|
|
784
|
+
remainder = trimmed.slice(colonIndex + 1);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
let source: string | undefined;
|
|
788
|
+
const atIndex = remainder.indexOf('@');
|
|
789
|
+
if (atIndex >= 0) {
|
|
790
|
+
source = remainder.slice(atIndex + 1).trim();
|
|
791
|
+
remainder = remainder.slice(0, atIndex);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const name = remainder.trim();
|
|
795
|
+
if (!name) {
|
|
796
|
+
throw new Error(`Invalid ${flag} value '${value}'. Schema name is required.`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (!ALLOWED_SCHEMA_KINDS.includes(kind as (typeof ALLOWED_SCHEMA_KINDS)[number])) {
|
|
800
|
+
throw new Error(
|
|
801
|
+
`Invalid schema kind '${kind}' in ${flag}. Allowed kinds: ${ALLOWED_SCHEMA_KINDS.join(', ')}.`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
kind: kind as SchemaReference['kind'],
|
|
807
|
+
name,
|
|
808
|
+
...(source ? { source } : {}),
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function parseResponseStatus(value: string | number | undefined): number | undefined {
|
|
813
|
+
if (value === undefined) {
|
|
814
|
+
return undefined;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const raw = typeof value === 'number' ? String(value) : value.trim();
|
|
818
|
+
if (!raw) {
|
|
819
|
+
return undefined;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const status = Number.parseInt(raw, 10);
|
|
823
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`Invalid --response-status value '${raw}'. Status must be between 100 and 599.`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return status;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function parsePriority(priority: string | undefined): { priority?: number | string } {
|
|
833
|
+
if (!priority) {
|
|
834
|
+
return {};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const numeric = Number.parseInt(priority, 10);
|
|
838
|
+
if (Number.isInteger(numeric) && String(numeric) === priority) {
|
|
839
|
+
return { priority: numeric };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return { priority };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function validateSchedule(schedule: string): void {
|
|
846
|
+
const trimmed = schedule.trim();
|
|
847
|
+
if (!trimmed) {
|
|
848
|
+
throw new Error('--schedule value cannot be empty or whitespace.');
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (trimmed.startsWith('@')) {
|
|
852
|
+
const macro = trimmed.slice(1);
|
|
853
|
+
if (
|
|
854
|
+
!ALLOWED_SCHEDULE_MACROS.includes(
|
|
855
|
+
macro.toLowerCase() as (typeof ALLOWED_SCHEDULE_MACROS)[number],
|
|
856
|
+
)
|
|
857
|
+
) {
|
|
858
|
+
throw new Error(
|
|
859
|
+
`Invalid --schedule value '${schedule}'. Allowed macros: ${ALLOWED_SCHEDULE_MACROS.map((value) => `@${value}`).join(', ')}.`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (trimmed.toLowerCase().startsWith('rate(')) {
|
|
866
|
+
validateRateSchedule(schedule, trimmed);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const parts = trimmed.split(/\s+/);
|
|
871
|
+
if (parts.length < 5 || parts.length > 7) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`Invalid --schedule value '${schedule}'. Expected 5-7 space-separated cron fields, @macro, or rate(...).`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
for (const part of parts) {
|
|
878
|
+
if (!isValidCronField(part)) {
|
|
879
|
+
throw new Error(`Invalid cron field '${part}' in --schedule value '${schedule}'.`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function validateRateSchedule(schedule: string, trimmed: string): void {
|
|
885
|
+
const match = RATE_SCHEDULE_PATTERN.exec(trimmed);
|
|
886
|
+
const value = match ? Number.parseInt(match[1], 10) : 0;
|
|
887
|
+
if (!match || value <= 0) {
|
|
888
|
+
throw new Error(
|
|
889
|
+
`Invalid --schedule value '${schedule}'. Expected rate(<positive integer> second(s)|minute(s)|hour(s)).`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function isValidCronField(field: string): boolean {
|
|
895
|
+
for (const character of field) {
|
|
896
|
+
if (/[A-Za-z0-9]/.test(character)) {
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if ('*/, -?#LWC'.includes(character)) {
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function readWorkspacePackageJson(
|
|
911
|
+
packageJsonPath: string,
|
|
912
|
+
): Promise<MutableWorkspacePackageJson> {
|
|
913
|
+
if (!existsSync(packageJsonPath)) {
|
|
914
|
+
throw new Error(`package.json not found in workspace root.`);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return JSON.parse(await readTextFile(packageJsonPath)) as MutableWorkspacePackageJson;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function writeWorkspacePackageJson(
|
|
921
|
+
packageJsonPath: string,
|
|
922
|
+
pkg: Record<string, unknown>,
|
|
923
|
+
): Promise<void> {
|
|
924
|
+
await writeTextFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
928
|
+
return typeof value === 'object' && value !== null
|
|
929
|
+
? { ...(value as Record<string, unknown>) }
|
|
930
|
+
: {};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function captureFileState(
|
|
934
|
+
absolutePaths: readonly string[],
|
|
935
|
+
): Promise<Map<string, string | null>> {
|
|
936
|
+
const state = new Map<string, string | null>();
|
|
937
|
+
for (const absolutePath of absolutePaths) {
|
|
938
|
+
state.set(absolutePath, await readFileIfExists(absolutePath));
|
|
939
|
+
}
|
|
940
|
+
return state;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function collectChangedFiles(
|
|
944
|
+
workspaceRoot: string,
|
|
945
|
+
absolutePaths: readonly string[],
|
|
946
|
+
before: ReadonlyMap<string, string | null>,
|
|
947
|
+
): Promise<string[]> {
|
|
948
|
+
const changes: string[] = [];
|
|
949
|
+
for (const absolutePath of absolutePaths) {
|
|
950
|
+
const current = await readFileIfExists(absolutePath);
|
|
951
|
+
if (current !== before.get(absolutePath)) {
|
|
952
|
+
changes.push(path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, '/'));
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return changes;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function readFileIfExists(absolutePath: string): Promise<string | null> {
|
|
959
|
+
if (!existsSync(absolutePath)) {
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
return await readTextFile(absolutePath);
|
|
964
|
+
}
|