@webstir-io/webstir 0.1.2 → 0.1.4
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/assets/features/search/search.ts +0 -9
- package/assets/templates/api/src/backend/index.ts +4 -13
- package/assets/templates/full/src/backend/index.ts +4 -13
- package/assets/templates/full/src/backend/module.ts +2 -2
- package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +11 -11
- package/package.json +3 -3
- package/scripts/run-tests.mjs +15 -11
- package/src/add-backend.ts +10 -72
- package/src/bun-spa-routes.ts +5 -18
- package/src/dev-server.ts +6 -19
- package/src/execute.ts +2 -0
- package/src/watch.ts +23 -16
- package/src/workspace-lock.ts +207 -0
- package/src/add-backend-compat.ts +0 -628
|
@@ -1,628 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import { mkdir, readFile, writeFile } 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
|
-
|
|
14
|
-
const ALLOWED_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as const;
|
|
15
|
-
const ALLOWED_SCHEMA_KINDS = ['zod', 'json-schema', 'ts-rest'] as const;
|
|
16
|
-
const ALLOWED_INTERACTIONS = ['navigation', 'mutation'] as const;
|
|
17
|
-
const ALLOWED_SESSION_MODES = ['optional', 'required'] as const;
|
|
18
|
-
const ALLOWED_FRAGMENT_MODES = ['replace', 'append', 'prepend'] as const;
|
|
19
|
-
const ALLOWED_SCHEDULE_MACROS = [
|
|
20
|
-
'yearly',
|
|
21
|
-
'annually',
|
|
22
|
-
'monthly',
|
|
23
|
-
'weekly',
|
|
24
|
-
'daily',
|
|
25
|
-
'midnight',
|
|
26
|
-
'hourly',
|
|
27
|
-
'reboot',
|
|
28
|
-
] as const;
|
|
29
|
-
const RATE_SCHEDULE_PATTERN = /^rate\((\d+)\s+(second|seconds|minute|minutes|hour|hours)\)$/i;
|
|
30
|
-
|
|
31
|
-
interface WorkspacePackageJson {
|
|
32
|
-
readonly webstir?: {
|
|
33
|
-
readonly moduleManifest?: {
|
|
34
|
-
readonly routes?: RouteDefinition[];
|
|
35
|
-
readonly jobs?: JobDefinition[];
|
|
36
|
-
readonly [key: string]: unknown;
|
|
37
|
-
};
|
|
38
|
-
readonly [key: string]: unknown;
|
|
39
|
-
};
|
|
40
|
-
readonly [key: string]: unknown;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
type MutableWorkspacePackageJson = WorkspacePackageJson & {
|
|
44
|
-
webstir?: Record<string, unknown>;
|
|
45
|
-
[key: string]: unknown;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export interface AddRouteOptions {
|
|
49
|
-
readonly workspaceRoot: string;
|
|
50
|
-
readonly name: string;
|
|
51
|
-
readonly method?: string;
|
|
52
|
-
readonly path?: string;
|
|
53
|
-
readonly summary?: string;
|
|
54
|
-
readonly description?: string;
|
|
55
|
-
readonly tags?: readonly string[];
|
|
56
|
-
readonly interaction?: string;
|
|
57
|
-
readonly sessionMode?: string;
|
|
58
|
-
readonly sessionWrite?: boolean;
|
|
59
|
-
readonly formUrlEncoded?: boolean;
|
|
60
|
-
readonly formCsrf?: boolean;
|
|
61
|
-
readonly fragmentTarget?: string;
|
|
62
|
-
readonly fragmentSelector?: string;
|
|
63
|
-
readonly fragmentMode?: string;
|
|
64
|
-
readonly paramsSchema?: string;
|
|
65
|
-
readonly querySchema?: string;
|
|
66
|
-
readonly bodySchema?: string;
|
|
67
|
-
readonly headersSchema?: string;
|
|
68
|
-
readonly responseSchema?: string;
|
|
69
|
-
readonly responseStatus?: string | number;
|
|
70
|
-
readonly responseHeadersSchema?: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface AddJobOptions {
|
|
74
|
-
readonly workspaceRoot: string;
|
|
75
|
-
readonly name: string;
|
|
76
|
-
readonly schedule?: string;
|
|
77
|
-
readonly description?: string;
|
|
78
|
-
readonly priority?: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface BackendAddResult {
|
|
82
|
-
readonly subject: 'route' | 'job';
|
|
83
|
-
readonly target: string;
|
|
84
|
-
readonly changes: readonly string[];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export async function runAddRoute(options: AddRouteOptions): Promise<BackendAddResult> {
|
|
88
|
-
const name = normalizeRequiredName(options.name, 'route');
|
|
89
|
-
const method = normalizeMethod(options.method);
|
|
90
|
-
const routePath = normalizeRoutePath(options.path, name);
|
|
91
|
-
const summary = normalizeOptionalString(options.summary);
|
|
92
|
-
const description = normalizeOptionalString(options.description);
|
|
93
|
-
const tags = normalizeTags(options.tags);
|
|
94
|
-
const interaction = normalizeInteraction(options.interaction);
|
|
95
|
-
const session = buildRouteSession(options.sessionMode, options.sessionWrite);
|
|
96
|
-
const form = buildRouteForm(options.formUrlEncoded, options.formCsrf);
|
|
97
|
-
const fragment = buildRouteFragment(
|
|
98
|
-
options.fragmentTarget,
|
|
99
|
-
options.fragmentSelector,
|
|
100
|
-
options.fragmentMode,
|
|
101
|
-
);
|
|
102
|
-
const paramsSchema = parseSchemaReference(options.paramsSchema, '--params-schema');
|
|
103
|
-
const querySchema = parseSchemaReference(options.querySchema, '--query-schema');
|
|
104
|
-
const bodySchema = parseSchemaReference(options.bodySchema, '--body-schema');
|
|
105
|
-
const headersSchema = parseSchemaReference(options.headersSchema, '--headers-schema');
|
|
106
|
-
const responseSchema = parseSchemaReference(options.responseSchema, '--response-schema');
|
|
107
|
-
const responseHeadersSchema = parseSchemaReference(
|
|
108
|
-
options.responseHeadersSchema,
|
|
109
|
-
'--response-headers-schema',
|
|
110
|
-
);
|
|
111
|
-
const responseStatus = parseResponseStatus(options.responseStatus);
|
|
112
|
-
|
|
113
|
-
if ((responseHeadersSchema || responseStatus !== undefined) && !responseSchema) {
|
|
114
|
-
throw new Error('--response-schema is required when setting response headers or status.');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const packageJsonPath = path.join(options.workspaceRoot, 'package.json');
|
|
118
|
-
const trackedPaths = [packageJsonPath];
|
|
119
|
-
const before = await captureFileState(trackedPaths);
|
|
120
|
-
|
|
121
|
-
const pkg = await readWorkspacePackageJson(packageJsonPath);
|
|
122
|
-
const webstir = asObject(pkg.webstir);
|
|
123
|
-
const moduleManifest = asObject(webstir.moduleManifest);
|
|
124
|
-
const routes = Array.isArray(moduleManifest.routes) ? [...moduleManifest.routes] : [];
|
|
125
|
-
const routeIndex = routes.findIndex(
|
|
126
|
-
(entry) => entry?.method === method && entry?.path === routePath,
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
const nextRoute: RouteDefinition = {
|
|
130
|
-
name,
|
|
131
|
-
method,
|
|
132
|
-
path: routePath,
|
|
133
|
-
...(summary ? { summary } : {}),
|
|
134
|
-
...(description ? { description } : {}),
|
|
135
|
-
...(tags.length > 0 ? { tags } : {}),
|
|
136
|
-
...(interaction ? { interaction } : {}),
|
|
137
|
-
...(session ? { session } : {}),
|
|
138
|
-
...(form ? { form } : {}),
|
|
139
|
-
...(fragment ? { fragment } : {}),
|
|
140
|
-
...buildRouteInput(paramsSchema, querySchema, bodySchema, headersSchema),
|
|
141
|
-
...buildRouteOutput(responseSchema, responseHeadersSchema, responseStatus),
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
if (routeIndex >= 0) {
|
|
145
|
-
routes[routeIndex] = nextRoute;
|
|
146
|
-
} else {
|
|
147
|
-
routes.push(nextRoute);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
moduleManifest.routes = routes;
|
|
151
|
-
webstir.moduleManifest = moduleManifest;
|
|
152
|
-
pkg.webstir = webstir;
|
|
153
|
-
await writeWorkspacePackageJson(packageJsonPath, pkg);
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
subject: 'route',
|
|
157
|
-
target: `${method} ${routePath}`,
|
|
158
|
-
changes: await collectChangedFiles(options.workspaceRoot, trackedPaths, before),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export async function runAddJob(options: AddJobOptions): Promise<BackendAddResult> {
|
|
163
|
-
const name = normalizeRequiredName(options.name, 'job');
|
|
164
|
-
const schedule = normalizeOptionalString(options.schedule);
|
|
165
|
-
const description = normalizeOptionalString(options.description);
|
|
166
|
-
const priority = normalizeOptionalString(options.priority);
|
|
167
|
-
|
|
168
|
-
if (schedule) {
|
|
169
|
-
validateSchedule(schedule);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const packageJsonPath = path.join(options.workspaceRoot, 'package.json');
|
|
173
|
-
const jobDirectory = path.join(options.workspaceRoot, 'src', 'backend', 'jobs', name);
|
|
174
|
-
const jobFilePath = path.join(jobDirectory, 'index.ts');
|
|
175
|
-
const trackedPaths = [packageJsonPath, jobFilePath];
|
|
176
|
-
const before = await captureFileState(trackedPaths);
|
|
177
|
-
|
|
178
|
-
if (existsSync(jobDirectory)) {
|
|
179
|
-
throw new Error(`Job '${name}' already exists.`);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
await mkdir(jobDirectory, { recursive: true });
|
|
183
|
-
await writeTextFile(jobFilePath, buildJobTemplate(name));
|
|
184
|
-
|
|
185
|
-
const pkg = await readWorkspacePackageJson(packageJsonPath);
|
|
186
|
-
const webstir = asObject(pkg.webstir);
|
|
187
|
-
const moduleManifest = asObject(webstir.moduleManifest);
|
|
188
|
-
const jobs = Array.isArray(moduleManifest.jobs) ? [...moduleManifest.jobs] : [];
|
|
189
|
-
const jobIndex = jobs.findIndex((entry) => entry?.name === name);
|
|
190
|
-
|
|
191
|
-
const nextJob: JobDefinition & { description?: string } = {
|
|
192
|
-
name,
|
|
193
|
-
...(schedule ? { schedule } : {}),
|
|
194
|
-
...(description ? { description } : {}),
|
|
195
|
-
...parsePriority(priority),
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
if (jobIndex >= 0) {
|
|
199
|
-
jobs[jobIndex] = nextJob;
|
|
200
|
-
} else {
|
|
201
|
-
jobs.push(nextJob);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
moduleManifest.jobs = jobs;
|
|
205
|
-
webstir.moduleManifest = moduleManifest;
|
|
206
|
-
pkg.webstir = webstir;
|
|
207
|
-
await writeWorkspacePackageJson(packageJsonPath, pkg);
|
|
208
|
-
|
|
209
|
-
return {
|
|
210
|
-
subject: 'job',
|
|
211
|
-
target: name,
|
|
212
|
-
changes: await collectChangedFiles(options.workspaceRoot, trackedPaths, before),
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function buildRouteInput(
|
|
217
|
-
paramsSchema?: SchemaReference,
|
|
218
|
-
querySchema?: SchemaReference,
|
|
219
|
-
bodySchema?: SchemaReference,
|
|
220
|
-
headersSchema?: SchemaReference,
|
|
221
|
-
): { input?: RouteDefinition['input'] } {
|
|
222
|
-
const input = {
|
|
223
|
-
...(paramsSchema ? { params: paramsSchema } : {}),
|
|
224
|
-
...(querySchema ? { query: querySchema } : {}),
|
|
225
|
-
...(bodySchema ? { body: bodySchema } : {}),
|
|
226
|
-
...(headersSchema ? { headers: headersSchema } : {}),
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
return Object.keys(input).length > 0 ? { input } : {};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function buildRouteOutput(
|
|
233
|
-
responseSchema?: SchemaReference,
|
|
234
|
-
responseHeadersSchema?: SchemaReference,
|
|
235
|
-
responseStatus?: number,
|
|
236
|
-
): { output?: RouteDefinition['output'] } {
|
|
237
|
-
if (!responseSchema) {
|
|
238
|
-
return {};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
output: {
|
|
243
|
-
body: responseSchema,
|
|
244
|
-
...(responseStatus !== undefined ? { status: responseStatus } : {}),
|
|
245
|
-
...(responseHeadersSchema ? { headers: responseHeadersSchema } : {}),
|
|
246
|
-
},
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function normalizeInteraction(value?: string): RouteDefinition['interaction'] | undefined {
|
|
251
|
-
const normalized = normalizeOptionalString(value);
|
|
252
|
-
if (!normalized) {
|
|
253
|
-
return undefined;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (normalized === 'navigation' || normalized === 'mutation') {
|
|
257
|
-
return normalized;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
throw new Error(
|
|
261
|
-
`Invalid --interaction value '${normalized}'. Allowed values: ${ALLOWED_INTERACTIONS.join(', ')}.`,
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function buildRouteSession(mode?: string, write?: boolean): RouteDefinition['session'] | undefined {
|
|
266
|
-
const normalizedMode = normalizeSessionMode(mode);
|
|
267
|
-
if (!normalizedMode && !write) {
|
|
268
|
-
return undefined;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
return {
|
|
272
|
-
...(normalizedMode ? { mode: normalizedMode } : {}),
|
|
273
|
-
...(write ? { write: true } : {}),
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function normalizeSessionMode(value?: string): SessionAccessMode | undefined {
|
|
278
|
-
const normalized = normalizeOptionalString(value);
|
|
279
|
-
if (!normalized) {
|
|
280
|
-
return undefined;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (normalized === 'optional' || normalized === 'required') {
|
|
284
|
-
return normalized;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
throw new Error(
|
|
288
|
-
`Invalid --session value '${normalized}'. Allowed values: ${ALLOWED_SESSION_MODES.join(', ')}.`,
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function buildRouteForm(urlEncoded?: boolean, csrf?: boolean): RouteDefinition['form'] | undefined {
|
|
293
|
-
if (!urlEncoded && !csrf) {
|
|
294
|
-
return undefined;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return {
|
|
298
|
-
...(urlEncoded ? { contentType: 'application/x-www-form-urlencoded' } : {}),
|
|
299
|
-
...(csrf ? { csrf: true } : {}),
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function buildRouteFragment(
|
|
304
|
-
target?: string,
|
|
305
|
-
selector?: string,
|
|
306
|
-
mode?: string,
|
|
307
|
-
): RouteDefinition['fragment'] | undefined {
|
|
308
|
-
const normalizedTarget = normalizeOptionalString(target);
|
|
309
|
-
const normalizedSelector = normalizeOptionalString(selector);
|
|
310
|
-
const normalizedMode = normalizeFragmentMode(mode);
|
|
311
|
-
|
|
312
|
-
if (!normalizedTarget && !normalizedSelector && !normalizedMode) {
|
|
313
|
-
return undefined;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (!normalizedTarget) {
|
|
317
|
-
throw new Error('--fragment-target is required when setting fragment metadata.');
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return {
|
|
321
|
-
target: normalizedTarget,
|
|
322
|
-
...(normalizedSelector ? { selector: normalizedSelector } : {}),
|
|
323
|
-
...(normalizedMode ? { mode: normalizedMode } : {}),
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function normalizeFragmentMode(value?: string): FragmentUpdateMode | undefined {
|
|
328
|
-
const normalized = normalizeOptionalString(value);
|
|
329
|
-
if (!normalized) {
|
|
330
|
-
return undefined;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (normalized === 'replace' || normalized === 'append' || normalized === 'prepend') {
|
|
334
|
-
return normalized;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
throw new Error(
|
|
338
|
-
`Invalid --fragment-mode value '${normalized}'. Allowed values: ${ALLOWED_FRAGMENT_MODES.join(', ')}.`,
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function buildJobTemplate(name: string): string {
|
|
343
|
-
return `// Generated by webstir add-job
|
|
344
|
-
export async function run(): Promise<void> {
|
|
345
|
-
console.info('[job:${name}] ran at', new Date().toISOString());
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Execute when launched directly: \`bun build/backend/jobs/${name}/index.js\`
|
|
349
|
-
const isMain = (() => {
|
|
350
|
-
try {
|
|
351
|
-
const argv1 = process.argv?.[1];
|
|
352
|
-
if (!argv1) return false;
|
|
353
|
-
const here = new URL(import.meta.url);
|
|
354
|
-
const run = new URL(\`file://\${argv1}\`);
|
|
355
|
-
return here.pathname === run.pathname;
|
|
356
|
-
} catch {
|
|
357
|
-
return false;
|
|
358
|
-
}
|
|
359
|
-
})();
|
|
360
|
-
|
|
361
|
-
if (isMain) {
|
|
362
|
-
run().catch((err) => {
|
|
363
|
-
console.error(err);
|
|
364
|
-
process.exitCode = 1;
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
`;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function normalizeRequiredName(name: string, subject: 'route' | 'job'): string {
|
|
371
|
-
const normalized = name.trim();
|
|
372
|
-
if (!normalized) {
|
|
373
|
-
throw new Error(`Missing ${subject} name.`);
|
|
374
|
-
}
|
|
375
|
-
return normalized;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function normalizeMethod(value?: string): HttpMethod {
|
|
379
|
-
const candidate = (value ?? 'GET').trim().toUpperCase();
|
|
380
|
-
if (!ALLOWED_METHODS.includes(candidate as HttpMethod)) {
|
|
381
|
-
throw new Error(
|
|
382
|
-
`Invalid --method value '${candidate}'. Allowed values: ${ALLOWED_METHODS.join(', ')}.`,
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
return candidate as HttpMethod;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function normalizeRoutePath(value: string | undefined, name: string): string {
|
|
389
|
-
const routePath = value?.trim() || `/api/${name}`;
|
|
390
|
-
return routePath.startsWith('/') ? routePath : `/${routePath}`;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function normalizeOptionalString(value?: string): string | undefined {
|
|
394
|
-
const normalized = value?.trim();
|
|
395
|
-
return normalized ? normalized : undefined;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function normalizeTags(tags: readonly string[] | undefined): string[] {
|
|
399
|
-
if (!tags || tags.length === 0) {
|
|
400
|
-
return [];
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const seen = new Set<string>();
|
|
404
|
-
const normalized: string[] = [];
|
|
405
|
-
for (const tag of tags) {
|
|
406
|
-
const candidate = tag.trim();
|
|
407
|
-
if (!candidate) {
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const key = candidate.toLowerCase();
|
|
412
|
-
if (seen.has(key)) {
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
seen.add(key);
|
|
417
|
-
normalized.push(candidate);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
return normalized;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function parseSchemaReference(
|
|
424
|
-
value: string | undefined,
|
|
425
|
-
flag: string,
|
|
426
|
-
): SchemaReference | undefined {
|
|
427
|
-
if (!value) {
|
|
428
|
-
return undefined;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const trimmed = value.trim();
|
|
432
|
-
let kind = 'zod';
|
|
433
|
-
let remainder = trimmed;
|
|
434
|
-
const colonIndex = trimmed.indexOf(':');
|
|
435
|
-
if (colonIndex >= 0) {
|
|
436
|
-
if (colonIndex === 0) {
|
|
437
|
-
throw new Error(`Invalid ${flag} value '${value}'. Missing schema name.`);
|
|
438
|
-
}
|
|
439
|
-
kind = trimmed.slice(0, colonIndex).trim().toLowerCase();
|
|
440
|
-
remainder = trimmed.slice(colonIndex + 1);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
let source: string | undefined;
|
|
444
|
-
const atIndex = remainder.indexOf('@');
|
|
445
|
-
if (atIndex >= 0) {
|
|
446
|
-
source = remainder.slice(atIndex + 1).trim();
|
|
447
|
-
remainder = remainder.slice(0, atIndex);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const name = remainder.trim();
|
|
451
|
-
if (!name) {
|
|
452
|
-
throw new Error(`Invalid ${flag} value '${value}'. Schema name is required.`);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (!ALLOWED_SCHEMA_KINDS.includes(kind as (typeof ALLOWED_SCHEMA_KINDS)[number])) {
|
|
456
|
-
throw new Error(
|
|
457
|
-
`Invalid schema kind '${kind}' in ${flag}. Allowed kinds: ${ALLOWED_SCHEMA_KINDS.join(', ')}.`,
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
return {
|
|
462
|
-
kind: kind as SchemaReference['kind'],
|
|
463
|
-
name,
|
|
464
|
-
...(source ? { source } : {}),
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function parseResponseStatus(value: string | number | undefined): number | undefined {
|
|
469
|
-
if (value === undefined) {
|
|
470
|
-
return undefined;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const raw = typeof value === 'number' ? String(value) : value.trim();
|
|
474
|
-
if (!raw) {
|
|
475
|
-
return undefined;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const status = Number.parseInt(raw, 10);
|
|
479
|
-
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
480
|
-
throw new Error(
|
|
481
|
-
`Invalid --response-status value '${raw}'. Status must be between 100 and 599.`,
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
return status;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function parsePriority(priority: string | undefined): { priority?: number | string } {
|
|
489
|
-
if (!priority) {
|
|
490
|
-
return {};
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
const numeric = Number.parseInt(priority, 10);
|
|
494
|
-
if (Number.isInteger(numeric) && String(numeric) === priority) {
|
|
495
|
-
return { priority: numeric };
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
return { priority };
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function validateSchedule(schedule: string): void {
|
|
502
|
-
const trimmed = schedule.trim();
|
|
503
|
-
if (!trimmed) {
|
|
504
|
-
throw new Error('--schedule value cannot be empty or whitespace.');
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
if (trimmed.startsWith('@')) {
|
|
508
|
-
const macro = trimmed.slice(1);
|
|
509
|
-
if (
|
|
510
|
-
!ALLOWED_SCHEDULE_MACROS.includes(
|
|
511
|
-
macro.toLowerCase() as (typeof ALLOWED_SCHEDULE_MACROS)[number],
|
|
512
|
-
)
|
|
513
|
-
) {
|
|
514
|
-
throw new Error(
|
|
515
|
-
`Invalid --schedule value '${schedule}'. Allowed macros: ${ALLOWED_SCHEDULE_MACROS.map((value) => `@${value}`).join(', ')}.`,
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
if (trimmed.toLowerCase().startsWith('rate(')) {
|
|
522
|
-
validateRateSchedule(schedule, trimmed);
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const parts = trimmed.split(/\s+/);
|
|
527
|
-
if (parts.length < 5 || parts.length > 7) {
|
|
528
|
-
throw new Error(
|
|
529
|
-
`Invalid --schedule value '${schedule}'. Expected 5-7 space-separated cron fields, @macro, or rate(...).`,
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
for (const part of parts) {
|
|
534
|
-
if (!isValidCronField(part)) {
|
|
535
|
-
throw new Error(`Invalid cron field '${part}' in --schedule value '${schedule}'.`);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function validateRateSchedule(schedule: string, trimmed: string): void {
|
|
541
|
-
const match = RATE_SCHEDULE_PATTERN.exec(trimmed);
|
|
542
|
-
const value = match ? Number.parseInt(match[1], 10) : 0;
|
|
543
|
-
if (!match || value <= 0) {
|
|
544
|
-
throw new Error(
|
|
545
|
-
`Invalid --schedule value '${schedule}'. Expected rate(<positive integer> second(s)|minute(s)|hour(s)).`,
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function isValidCronField(field: string): boolean {
|
|
551
|
-
for (const character of field) {
|
|
552
|
-
if (/[A-Za-z0-9]/.test(character)) {
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if ('*/, -?#LWC'.includes(character)) {
|
|
557
|
-
continue;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
return false;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
return true;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
async function readWorkspacePackageJson(
|
|
567
|
-
packageJsonPath: string,
|
|
568
|
-
): Promise<MutableWorkspacePackageJson> {
|
|
569
|
-
if (!existsSync(packageJsonPath)) {
|
|
570
|
-
throw new Error('package.json not found in workspace root.');
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
return JSON.parse(await readTextFile(packageJsonPath)) as MutableWorkspacePackageJson;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
async function writeWorkspacePackageJson(
|
|
577
|
-
packageJsonPath: string,
|
|
578
|
-
pkg: Record<string, unknown>,
|
|
579
|
-
): Promise<void> {
|
|
580
|
-
await writeTextFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function asObject(value: unknown): Record<string, unknown> {
|
|
584
|
-
return typeof value === 'object' && value !== null
|
|
585
|
-
? { ...(value as Record<string, unknown>) }
|
|
586
|
-
: {};
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
async function captureFileState(
|
|
590
|
-
absolutePaths: readonly string[],
|
|
591
|
-
): Promise<Map<string, string | null>> {
|
|
592
|
-
const state = new Map<string, string | null>();
|
|
593
|
-
for (const absolutePath of absolutePaths) {
|
|
594
|
-
state.set(absolutePath, await readFileIfExists(absolutePath));
|
|
595
|
-
}
|
|
596
|
-
return state;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
async function collectChangedFiles(
|
|
600
|
-
workspaceRoot: string,
|
|
601
|
-
absolutePaths: readonly string[],
|
|
602
|
-
before: ReadonlyMap<string, string | null>,
|
|
603
|
-
): Promise<string[]> {
|
|
604
|
-
const changes: string[] = [];
|
|
605
|
-
for (const absolutePath of absolutePaths) {
|
|
606
|
-
const current = await readFileIfExists(absolutePath);
|
|
607
|
-
if (current !== before.get(absolutePath)) {
|
|
608
|
-
changes.push(path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, '/'));
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
return changes;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
async function readFileIfExists(absolutePath: string): Promise<string | null> {
|
|
615
|
-
if (!existsSync(absolutePath)) {
|
|
616
|
-
return null;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
return await readTextFile(absolutePath);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
async function readTextFile(filePath: string): Promise<string> {
|
|
623
|
-
return await readFile(filePath, 'utf8');
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
async function writeTextFile(filePath: string, contents: string): Promise<void> {
|
|
627
|
-
await writeFile(filePath, contents, 'utf8');
|
|
628
|
-
}
|