@ontrails/trails 1.0.0-beta.18 → 1.0.0-beta.19
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/CHANGELOG.md +117 -0
- package/README.md +7 -10
- package/package.json +13 -12
- package/src/app.ts +14 -4
- package/src/cli.ts +16 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/project-writes.ts +62 -5
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-collision.ts +1 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +18 -18
- package/src/trails/add-trail.ts +3 -2
- package/src/trails/add-verify.ts +30 -6
- package/src/trails/{topo-compile.ts → compile.ts} +16 -8
- package/src/trails/completions-complete.ts +1 -1
- package/src/trails/create-adapter.ts +1084 -0
- package/src/trails/create-scaffold.ts +243 -29
- package/src/trails/create.ts +118 -17
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +2 -2
- package/src/trails/dev-reset.ts +2 -2
- package/src/trails/dev-stats.ts +1 -1
- package/src/trails/doctor.ts +56 -0
- package/src/trails/draft-promote.ts +1 -0
- package/src/trails/guide.ts +2 -2
- package/src/trails/revise.ts +53 -0
- package/src/trails/run-example.ts +12 -7
- package/src/trails/run-examples.ts +3 -3
- package/src/trails/run.ts +7 -4
- package/src/trails/survey.ts +332 -25
- package/src/trails/topo-history.ts +1 -1
- package/src/trails/topo-output-schemas.ts +30 -1
- package/src/trails/topo-pin.ts +3 -2
- package/src/trails/topo-read-support.ts +49 -8
- package/src/trails/topo-reports.ts +39 -22
- package/src/trails/topo-store-support.ts +62 -16
- package/src/trails/topo-support.ts +1 -1
- package/src/trails/topo-unpin.ts +2 -2
- package/src/trails/topo.ts +2 -2
- package/src/trails/{topo-verify.ts → validate.ts} +7 -7
- package/src/trails/version-lifecycle-support.ts +945 -0
- package/src/trails/warden-guide.ts +8 -0
- package/src/trails/warden.ts +18 -2
- package/src/versions.ts +4 -1
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create.adapter` trail -- Scaffold an adapter authoring target.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
adapterTargetPlacements,
|
|
7
|
+
checkAdapters,
|
|
8
|
+
deriveAdapterTargetCatalog,
|
|
9
|
+
} from '@ontrails/adapter-kit';
|
|
10
|
+
import type {
|
|
11
|
+
AdapterCheckDiagnostic,
|
|
12
|
+
AdapterTargetCatalogEntry,
|
|
13
|
+
AdapterTargetConformanceManifest,
|
|
14
|
+
AdapterTargetPlacement,
|
|
15
|
+
} from '@ontrails/adapter-kit';
|
|
16
|
+
import { Result, trail, ValidationError } from '@ontrails/core';
|
|
17
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
applyProjectOperations,
|
|
23
|
+
planProjectOperations,
|
|
24
|
+
resolveProjectPath,
|
|
25
|
+
} from '../project-writes.js';
|
|
26
|
+
import type {
|
|
27
|
+
PlannedProjectOperation,
|
|
28
|
+
ProjectWriteOperation,
|
|
29
|
+
} from '../project-writes.js';
|
|
30
|
+
import { trailsPackageVersion } from '../versions.js';
|
|
31
|
+
import { resolveTrailRootDir } from './root-dir.js';
|
|
32
|
+
|
|
33
|
+
const adapterNamePattern = /^[a-z][a-z0-9-]*$/u;
|
|
34
|
+
const adapterNameMessage =
|
|
35
|
+
'Adapter name must be kebab-case, start with a lowercase letter, and contain only lowercase letters, digits, or "-".';
|
|
36
|
+
const packageNamePattern =
|
|
37
|
+
/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
|
|
38
|
+
const packageNameMessage =
|
|
39
|
+
'Package name must be a valid lowercase npm package name, optionally scoped.';
|
|
40
|
+
|
|
41
|
+
type CreateAdapterPlacement = AdapterTargetPlacement;
|
|
42
|
+
|
|
43
|
+
const createAdapterPlacements = ['extracted'] as const;
|
|
44
|
+
|
|
45
|
+
interface CreateAdapterInput {
|
|
46
|
+
readonly dryRun: boolean;
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly packageName?: string | undefined;
|
|
49
|
+
readonly placement: CreateAdapterPlacement;
|
|
50
|
+
readonly rootDir?: string | undefined;
|
|
51
|
+
readonly target: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CreateAdapterResult {
|
|
55
|
+
readonly adapterImport: string;
|
|
56
|
+
readonly created: readonly string[];
|
|
57
|
+
readonly diagnostics: readonly AdapterCheckDiagnostic[];
|
|
58
|
+
readonly dryRun: boolean;
|
|
59
|
+
readonly packageName: string;
|
|
60
|
+
readonly placement: CreateAdapterPlacement;
|
|
61
|
+
readonly plannedOperations: readonly PlannedProjectOperation[];
|
|
62
|
+
readonly targetKey: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface AdapterOperationPlan {
|
|
66
|
+
readonly adapterImport: string;
|
|
67
|
+
readonly packageName: string;
|
|
68
|
+
readonly operations: readonly ProjectWriteOperation[];
|
|
69
|
+
readonly targetKey: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface RootManifest {
|
|
73
|
+
readonly workspaces?: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface WorkspacePackageManifest {
|
|
77
|
+
readonly exports?: unknown;
|
|
78
|
+
readonly name?: unknown;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface WorkspacePackageName {
|
|
82
|
+
readonly name: string;
|
|
83
|
+
readonly workspacePath: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface LocalNamedReexport {
|
|
87
|
+
readonly local: string;
|
|
88
|
+
readonly specifier: string;
|
|
89
|
+
readonly typeOnly: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface LocalNamedImport {
|
|
93
|
+
readonly imported: string;
|
|
94
|
+
readonly specifier: string;
|
|
95
|
+
readonly typeOnly: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const literal = (value: string): string => JSON.stringify(value);
|
|
99
|
+
|
|
100
|
+
const writeOperation = (
|
|
101
|
+
path: string,
|
|
102
|
+
content: string
|
|
103
|
+
): ProjectWriteOperation => ({
|
|
104
|
+
content,
|
|
105
|
+
kind: 'write',
|
|
106
|
+
path,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const formatJson = (value: unknown): string =>
|
|
110
|
+
`${JSON.stringify(value, null, 2)}\n`;
|
|
111
|
+
|
|
112
|
+
const defaultExtractedPackageName = (name: string): string =>
|
|
113
|
+
`@ontrails/${name}`;
|
|
114
|
+
|
|
115
|
+
const fail = (message: string): Result<never, ValidationError> =>
|
|
116
|
+
Result.err(new ValidationError(message));
|
|
117
|
+
|
|
118
|
+
const readJson = <T>(path: string): T | undefined => {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const workspacePatternsFromManifest = (
|
|
127
|
+
manifest: RootManifest | undefined
|
|
128
|
+
): readonly string[] => {
|
|
129
|
+
const { workspaces } = manifest ?? {};
|
|
130
|
+
if (Array.isArray(workspaces)) {
|
|
131
|
+
return workspaces.filter(
|
|
132
|
+
(pattern): pattern is string => typeof pattern === 'string'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const packages =
|
|
137
|
+
workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)
|
|
138
|
+
? (workspaces as Record<string, unknown>)['packages']
|
|
139
|
+
: undefined;
|
|
140
|
+
return Array.isArray(packages)
|
|
141
|
+
? packages.filter(
|
|
142
|
+
(pattern): pattern is string => typeof pattern === 'string'
|
|
143
|
+
)
|
|
144
|
+
: [];
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const normalizeWorkspacePattern = (pattern: string): string =>
|
|
148
|
+
pattern.replace(/^\.\//u, '').replace(/\/+$/u, '');
|
|
149
|
+
|
|
150
|
+
const workspacePatternCoversPath = (
|
|
151
|
+
pattern: string,
|
|
152
|
+
workspacePath: string
|
|
153
|
+
): boolean => {
|
|
154
|
+
const normalized = normalizeWorkspacePattern(pattern);
|
|
155
|
+
if (normalized.endsWith('/*')) {
|
|
156
|
+
const prefix = normalized.slice(0, -2);
|
|
157
|
+
const rest = workspacePath.slice(prefix.length + 1);
|
|
158
|
+
return (
|
|
159
|
+
workspacePath.startsWith(`${prefix}/`) &&
|
|
160
|
+
rest.length > 0 &&
|
|
161
|
+
!rest.includes('/')
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return normalized === workspacePath;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const rootWorkspaceIncludesPath = (
|
|
169
|
+
rootDir: string,
|
|
170
|
+
workspacePath: string
|
|
171
|
+
): boolean => {
|
|
172
|
+
const rootManifest = readJson<RootManifest>(join(rootDir, 'package.json'));
|
|
173
|
+
return workspacePatternsFromManifest(rootManifest).some((pattern) =>
|
|
174
|
+
workspacePatternCoversPath(pattern, workspacePath)
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const workspaceDirsForPattern = (
|
|
179
|
+
rootDir: string,
|
|
180
|
+
pattern: string
|
|
181
|
+
): readonly string[] => {
|
|
182
|
+
if (!pattern.endsWith('/*')) {
|
|
183
|
+
const workspaceDir = join(rootDir, pattern);
|
|
184
|
+
return existsSync(workspaceDir) ? [workspaceDir] : [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const groupDir = join(rootDir, pattern.slice(0, -2));
|
|
188
|
+
if (!existsSync(groupDir)) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return readdirSync(groupDir, { withFileTypes: true })
|
|
193
|
+
.filter((entry) => entry.isDirectory())
|
|
194
|
+
.map((entry) => join(groupDir, entry.name))
|
|
195
|
+
.toSorted();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const findWorkspacePackageName = (
|
|
199
|
+
rootDir: string,
|
|
200
|
+
packageName: string
|
|
201
|
+
): WorkspacePackageName | undefined => {
|
|
202
|
+
const rootManifest = readJson<RootManifest>(join(rootDir, 'package.json'));
|
|
203
|
+
for (const pattern of workspacePatternsFromManifest(rootManifest)) {
|
|
204
|
+
for (const workspaceDir of workspaceDirsForPattern(rootDir, pattern)) {
|
|
205
|
+
const manifest = readJson<WorkspacePackageManifest>(
|
|
206
|
+
join(workspaceDir, 'package.json')
|
|
207
|
+
);
|
|
208
|
+
if (manifest?.name === packageName) {
|
|
209
|
+
return {
|
|
210
|
+
name: packageName,
|
|
211
|
+
workspacePath: relative(rootDir, workspaceDir),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return undefined;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const resolvePhysicalRootDir = (
|
|
221
|
+
rootDir: string
|
|
222
|
+
): Result<string, ValidationError> => {
|
|
223
|
+
try {
|
|
224
|
+
return Result.ok(realpathSync(resolve(rootDir)));
|
|
225
|
+
} catch {
|
|
226
|
+
return fail(
|
|
227
|
+
`Workspace root "${rootDir}" does not exist or cannot be read.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
233
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
234
|
+
|
|
235
|
+
const runtimeExportTarget = (value: unknown, depth = 0): string | undefined => {
|
|
236
|
+
if (typeof value === 'string') {
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
if (!isRecord(value) || depth > 8) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const condition of ['bun', 'import', 'default', 'require'] as const) {
|
|
244
|
+
const candidate = runtimeExportTarget(value[condition], depth + 1);
|
|
245
|
+
if (candidate) {
|
|
246
|
+
return candidate;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return undefined;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const packageRootExportTarget = (
|
|
254
|
+
target: AdapterTargetCatalogEntry
|
|
255
|
+
): string | undefined => {
|
|
256
|
+
const manifest = readJson<WorkspacePackageManifest>(target.packageJsonPath);
|
|
257
|
+
let exportTarget: string | undefined;
|
|
258
|
+
if (typeof manifest?.exports === 'string') {
|
|
259
|
+
exportTarget = manifest.exports;
|
|
260
|
+
} else if (isRecord(manifest?.exports)) {
|
|
261
|
+
const rootExport = Object.hasOwn(manifest.exports, '.')
|
|
262
|
+
? manifest.exports['.']
|
|
263
|
+
: manifest.exports;
|
|
264
|
+
exportTarget = runtimeExportTarget(rootExport);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!exportTarget || exportTarget.startsWith('..')) {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return resolve(target.packageRoot, exportTarget);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const maskSource = (source: string, options: { strings: boolean }): string => {
|
|
275
|
+
const output = [...source];
|
|
276
|
+
let index = 0;
|
|
277
|
+
|
|
278
|
+
const maskRange = (start: number, end: number): void => {
|
|
279
|
+
for (let cursor = start; cursor < end; cursor += 1) {
|
|
280
|
+
if (output[cursor] !== '\n') {
|
|
281
|
+
output[cursor] = ' ';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const skipQuoted = (quote: '"' | "'" | '`'): void => {
|
|
287
|
+
const start = index;
|
|
288
|
+
index += 1;
|
|
289
|
+
while (index < source.length) {
|
|
290
|
+
if (source[index] === '\\') {
|
|
291
|
+
index += 2;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (source[index] === quote) {
|
|
295
|
+
index += 1;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
index += 1;
|
|
299
|
+
}
|
|
300
|
+
if (options.strings) {
|
|
301
|
+
maskRange(start, index);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
while (index < source.length) {
|
|
306
|
+
if (source.startsWith('//', index)) {
|
|
307
|
+
const end = source.indexOf('\n', index + 2);
|
|
308
|
+
const stop = end === -1 ? source.length : end;
|
|
309
|
+
maskRange(index, stop);
|
|
310
|
+
index = stop;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (source.startsWith('/*', index)) {
|
|
314
|
+
const end = source.indexOf('*/', index + 2);
|
|
315
|
+
const stop = end === -1 ? source.length : end + 2;
|
|
316
|
+
maskRange(index, stop);
|
|
317
|
+
index = stop;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const char = source[index];
|
|
321
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
322
|
+
skipQuoted(char);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
index += 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return output.join('');
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const sameFileExportListLocalForIdentifier = (
|
|
332
|
+
source: string,
|
|
333
|
+
identifier: string,
|
|
334
|
+
expected: 'type' | 'value'
|
|
335
|
+
): string | undefined => {
|
|
336
|
+
const exportCode = maskSource(source, { strings: false });
|
|
337
|
+
const stringsMaskedCode = maskSource(source, { strings: true });
|
|
338
|
+
const exportListPattern =
|
|
339
|
+
/\bexport\s+(?<typeOnly>type\s+)?\{(?<exports>[\s\S]*?)\}(?<from>\s+from\s+['"][^'"]+['"])?/gu;
|
|
340
|
+
for (const match of exportCode.matchAll(exportListPattern)) {
|
|
341
|
+
if (!stringsMaskedCode.startsWith('export', match.index ?? 0)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (match.groups?.['from']) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const namedExports = match.groups?.['exports'] ?? '';
|
|
350
|
+
for (const item of namedExports.split(',')) {
|
|
351
|
+
const trimmedItem = item.trim();
|
|
352
|
+
const specifierTypeOnly =
|
|
353
|
+
Boolean(match.groups?.['typeOnly']) || trimmedItem.startsWith('type ');
|
|
354
|
+
const specifierText = trimmedItem.replace(/^type\s+/u, '');
|
|
355
|
+
const exported =
|
|
356
|
+
/^(?<local>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<name>[A-Za-z_$][\w$]*))?$/u.exec(
|
|
357
|
+
specifierText
|
|
358
|
+
)?.groups;
|
|
359
|
+
if (
|
|
360
|
+
!exported?.['local'] ||
|
|
361
|
+
(exported['name'] ?? exported['local']) !== identifier
|
|
362
|
+
) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (expected === 'type') {
|
|
366
|
+
return exported['local'];
|
|
367
|
+
}
|
|
368
|
+
if (!specifierTypeOnly) {
|
|
369
|
+
return exported['local'];
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return undefined;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const declaresTypeBinding = (source: string, identifier: string): boolean => {
|
|
378
|
+
const code = maskSource(source, { strings: true });
|
|
379
|
+
const escapedIdentifier = identifier.replaceAll(
|
|
380
|
+
/[.*+?^${}()|[\]\\]/gu,
|
|
381
|
+
'\\$&'
|
|
382
|
+
);
|
|
383
|
+
return new RegExp(
|
|
384
|
+
`(?:^|[;\\n\\r])\\s*(?:export\\s+)?(?:declare\\s+)?(?:interface|type)\\s+${escapedIdentifier}\\b`,
|
|
385
|
+
'u'
|
|
386
|
+
).test(code);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const declaresValueBinding = (source: string, identifier: string): boolean => {
|
|
390
|
+
const code = maskSource(source, { strings: true });
|
|
391
|
+
const escapedIdentifier = identifier.replaceAll(
|
|
392
|
+
/[.*+?^${}()|[\]\\]/gu,
|
|
393
|
+
'\\$&'
|
|
394
|
+
);
|
|
395
|
+
return new RegExp(
|
|
396
|
+
`(?:^|[;\\n\\r])\\s*(?:export\\s+)?(?:(?:async\\s+)?function|const|let|var|class|enum)\\s+${escapedIdentifier}\\b`,
|
|
397
|
+
'u'
|
|
398
|
+
).test(code);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const sourceExportsIdentifier = (
|
|
402
|
+
source: string,
|
|
403
|
+
identifier: string,
|
|
404
|
+
expected: 'type' | 'value'
|
|
405
|
+
): boolean => {
|
|
406
|
+
const code = maskSource(source, { strings: true });
|
|
407
|
+
const escapedIdentifier = identifier.replaceAll(
|
|
408
|
+
/[.*+?^${}()|[\]\\]/gu,
|
|
409
|
+
'\\$&'
|
|
410
|
+
);
|
|
411
|
+
const valueDeclarationPattern = new RegExp(
|
|
412
|
+
`\\bexport\\s+(?:(?:async\\s+)?function|const|let|var|class|enum)\\s+${escapedIdentifier}\\b`,
|
|
413
|
+
'u'
|
|
414
|
+
);
|
|
415
|
+
if (expected === 'value' && valueDeclarationPattern.test(code)) {
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const typeDeclarationPattern = new RegExp(
|
|
420
|
+
`\\bexport\\s+(?:declare\\s+)?(?:interface|type)\\s+${escapedIdentifier}\\b`,
|
|
421
|
+
'u'
|
|
422
|
+
);
|
|
423
|
+
const sameFileLocal = sameFileExportListLocalForIdentifier(
|
|
424
|
+
source,
|
|
425
|
+
identifier,
|
|
426
|
+
expected
|
|
427
|
+
);
|
|
428
|
+
return (
|
|
429
|
+
(expected === 'type' &&
|
|
430
|
+
(typeDeclarationPattern.test(code) ||
|
|
431
|
+
(sameFileLocal !== undefined &&
|
|
432
|
+
declaresTypeBinding(source, sameFileLocal)))) ||
|
|
433
|
+
(expected === 'value' &&
|
|
434
|
+
sameFileLocal !== undefined &&
|
|
435
|
+
declaresValueBinding(source, sameFileLocal))
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const localNamedImports = (
|
|
440
|
+
source: string,
|
|
441
|
+
identifier: string
|
|
442
|
+
): readonly LocalNamedImport[] => {
|
|
443
|
+
const code = maskSource(source, { strings: false });
|
|
444
|
+
const stringsMaskedCode = maskSource(source, { strings: true });
|
|
445
|
+
const imports: LocalNamedImport[] = [];
|
|
446
|
+
const pattern =
|
|
447
|
+
/\bimport\s+(?<typeOnly>type\s+)?\{(?<imports>[\s\S]*?)\}\s+from\s+['"](?<specifier>[^'"]+)['"]/gu;
|
|
448
|
+
|
|
449
|
+
for (const match of code.matchAll(pattern)) {
|
|
450
|
+
if (!stringsMaskedCode.startsWith('import', match.index ?? 0)) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const specifier = match.groups?.['specifier'];
|
|
455
|
+
if (!specifier?.startsWith('.')) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const namedImports = match.groups?.['imports'] ?? '';
|
|
460
|
+
for (const item of namedImports.split(',')) {
|
|
461
|
+
const trimmedItem = item.trim();
|
|
462
|
+
if (!trimmedItem) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const specifierTypeOnly =
|
|
467
|
+
Boolean(match.groups?.['typeOnly']) || trimmedItem.startsWith('type ');
|
|
468
|
+
const specifierText = trimmedItem.replace(/^type\s+/u, '');
|
|
469
|
+
const imported =
|
|
470
|
+
/^(?<imported>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<local>[A-Za-z_$][\w$]*))?$/u.exec(
|
|
471
|
+
specifierText
|
|
472
|
+
)?.groups;
|
|
473
|
+
if (
|
|
474
|
+
!imported?.['imported'] ||
|
|
475
|
+
(imported['local'] ?? imported['imported']) !== identifier
|
|
476
|
+
) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
imports.push({
|
|
481
|
+
imported: imported['imported'],
|
|
482
|
+
specifier,
|
|
483
|
+
typeOnly: specifierTypeOnly,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return imports;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const localNamedReexports = (
|
|
492
|
+
source: string,
|
|
493
|
+
identifier: string
|
|
494
|
+
): readonly LocalNamedReexport[] => {
|
|
495
|
+
const code = maskSource(source, { strings: false });
|
|
496
|
+
const stringsMaskedCode = maskSource(source, { strings: true });
|
|
497
|
+
const exports: LocalNamedReexport[] = [];
|
|
498
|
+
const pattern =
|
|
499
|
+
/\bexport\s+(?<typeOnly>type\s+)?\{(?<exports>[\s\S]*?)\}\s+from\s+['"](?<specifier>[^'"]+)['"]/gu;
|
|
500
|
+
|
|
501
|
+
for (const match of code.matchAll(pattern)) {
|
|
502
|
+
if (!stringsMaskedCode.startsWith('export', match.index ?? 0)) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const specifier = match.groups?.['specifier'];
|
|
507
|
+
if (!specifier?.startsWith('.')) {
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const namedExports = match.groups?.['exports'] ?? '';
|
|
512
|
+
for (const item of namedExports.split(',')) {
|
|
513
|
+
const trimmedItem = item.trim();
|
|
514
|
+
if (!trimmedItem) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const specifierTypeOnly =
|
|
519
|
+
Boolean(match.groups?.['typeOnly']) || trimmedItem.startsWith('type ');
|
|
520
|
+
const specifierText = trimmedItem.replace(/^type\s+/u, '');
|
|
521
|
+
const exported =
|
|
522
|
+
/^(?<local>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<name>[A-Za-z_$][\w$]*))?$/u.exec(
|
|
523
|
+
specifierText
|
|
524
|
+
)?.groups;
|
|
525
|
+
if (
|
|
526
|
+
!exported?.['local'] ||
|
|
527
|
+
(exported['name'] ?? exported['local']) !== identifier
|
|
528
|
+
) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
exports.push({
|
|
533
|
+
local: exported['local'],
|
|
534
|
+
specifier,
|
|
535
|
+
typeOnly: specifierTypeOnly,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return exports;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const localStarReexports = (
|
|
544
|
+
source: string
|
|
545
|
+
): readonly { readonly specifier: string; readonly typeOnly: boolean }[] => {
|
|
546
|
+
const code = maskSource(source, { strings: false });
|
|
547
|
+
const stringsMaskedCode = maskSource(source, { strings: true });
|
|
548
|
+
return [
|
|
549
|
+
...code.matchAll(
|
|
550
|
+
/\bexport\s+(?<typeOnly>type\s+)?\*\s+from\s+['"](?<specifier>[^'"]+)['"]/gu
|
|
551
|
+
),
|
|
552
|
+
]
|
|
553
|
+
.filter((match) => stringsMaskedCode.startsWith('export', match.index ?? 0))
|
|
554
|
+
.map((match) => ({
|
|
555
|
+
specifier: match.groups?.['specifier'] ?? '',
|
|
556
|
+
typeOnly: Boolean(match.groups?.['typeOnly']),
|
|
557
|
+
}))
|
|
558
|
+
.filter((entry) => entry.specifier.startsWith('.'));
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const resolveLocalModuleSpecifier = (
|
|
562
|
+
sourcePath: string,
|
|
563
|
+
specifier: string
|
|
564
|
+
): string | undefined => {
|
|
565
|
+
const basePath = resolve(dirname(sourcePath), specifier);
|
|
566
|
+
const candidates = [
|
|
567
|
+
basePath,
|
|
568
|
+
basePath.endsWith('.js') ? `${basePath.slice(0, -3)}.ts` : undefined,
|
|
569
|
+
basePath.endsWith('.js') ? `${basePath.slice(0, -3)}.tsx` : undefined,
|
|
570
|
+
`${basePath}.ts`,
|
|
571
|
+
`${basePath}.tsx`,
|
|
572
|
+
join(basePath, 'index.ts'),
|
|
573
|
+
join(basePath, 'index.tsx'),
|
|
574
|
+
].filter((candidate): candidate is string => candidate !== undefined);
|
|
575
|
+
|
|
576
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const sourcePathExportsIdentifier = (
|
|
580
|
+
sourcePath: string,
|
|
581
|
+
identifier: string,
|
|
582
|
+
expected: 'type' | 'value',
|
|
583
|
+
visited = new Set<string>()
|
|
584
|
+
): boolean => {
|
|
585
|
+
const normalizedSourcePath = realpathSync(sourcePath);
|
|
586
|
+
const visitKey = `${normalizedSourcePath}:${identifier}:${expected}`;
|
|
587
|
+
if (visited.has(visitKey)) {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
visited.add(visitKey);
|
|
591
|
+
|
|
592
|
+
const source = readFileSync(normalizedSourcePath, 'utf8');
|
|
593
|
+
if (sourceExportsIdentifier(source, identifier, expected)) {
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const sameFileLocal = sameFileExportListLocalForIdentifier(
|
|
598
|
+
source,
|
|
599
|
+
identifier,
|
|
600
|
+
expected
|
|
601
|
+
);
|
|
602
|
+
if (sameFileLocal) {
|
|
603
|
+
for (const localImport of localNamedImports(source, sameFileLocal)) {
|
|
604
|
+
if (expected === 'value' && localImport.typeOnly) {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const targetPath = resolveLocalModuleSpecifier(
|
|
608
|
+
normalizedSourcePath,
|
|
609
|
+
localImport.specifier
|
|
610
|
+
);
|
|
611
|
+
if (
|
|
612
|
+
targetPath &&
|
|
613
|
+
sourcePathExportsIdentifier(
|
|
614
|
+
targetPath,
|
|
615
|
+
localImport.imported,
|
|
616
|
+
expected,
|
|
617
|
+
visited
|
|
618
|
+
)
|
|
619
|
+
) {
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
for (const reexport of localNamedReexports(source, identifier)) {
|
|
626
|
+
if (expected === 'value' && reexport.typeOnly) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const targetPath = resolveLocalModuleSpecifier(
|
|
630
|
+
normalizedSourcePath,
|
|
631
|
+
reexport.specifier
|
|
632
|
+
);
|
|
633
|
+
if (
|
|
634
|
+
targetPath &&
|
|
635
|
+
sourcePathExportsIdentifier(targetPath, reexport.local, expected, visited)
|
|
636
|
+
) {
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
for (const reexport of localStarReexports(source)) {
|
|
642
|
+
if (expected === 'value' && reexport.typeOnly) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const targetPath = resolveLocalModuleSpecifier(
|
|
646
|
+
normalizedSourcePath,
|
|
647
|
+
reexport.specifier
|
|
648
|
+
);
|
|
649
|
+
if (
|
|
650
|
+
targetPath &&
|
|
651
|
+
sourcePathExportsIdentifier(targetPath, identifier, expected, visited)
|
|
652
|
+
) {
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return false;
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const assertHttpOwnerSupport = (
|
|
661
|
+
target: AdapterTargetCatalogEntry
|
|
662
|
+
): Result<void, ValidationError> => {
|
|
663
|
+
const rootExportTarget = packageRootExportTarget(target);
|
|
664
|
+
if (!rootExportTarget || !existsSync(rootExportTarget)) {
|
|
665
|
+
return fail(
|
|
666
|
+
`Adapter target "${target.key}" cannot use the HTTP create.adapter template because ${target.ownerPackage} does not expose a readable package root export.`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const requiredExports = [
|
|
671
|
+
['createFetchHandler', 'value'],
|
|
672
|
+
['CreateFetchHandlerOptions', 'type'],
|
|
673
|
+
] as const;
|
|
674
|
+
const missing = requiredExports.filter(
|
|
675
|
+
([identifier, expected]) =>
|
|
676
|
+
!sourcePathExportsIdentifier(rootExportTarget, identifier, expected)
|
|
677
|
+
);
|
|
678
|
+
if (missing.length === 0) {
|
|
679
|
+
return Result.ok();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return fail(
|
|
683
|
+
`Adapter target "${target.key}" cannot use the HTTP create.adapter template because ${target.ownerPackage} does not export ${missing
|
|
684
|
+
.map(([identifier]) => identifier)
|
|
685
|
+
.join(' and ')} from its package root.`
|
|
686
|
+
);
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const assertHttpTemplate = (
|
|
690
|
+
target: AdapterTargetCatalogEntry
|
|
691
|
+
): Result<void, ValidationError> => {
|
|
692
|
+
if (target.target !== 'http') {
|
|
693
|
+
return fail(
|
|
694
|
+
`Adapter target "${target.target}" does not yet expose a create.adapter starter template.`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return assertHttpOwnerSupport(target);
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const assertConformanceScaffold = (
|
|
702
|
+
target: AdapterTargetCatalogEntry
|
|
703
|
+
): Result<void, ValidationError> => {
|
|
704
|
+
if (target.testingImport && target.conformance) {
|
|
705
|
+
return Result.ok();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return fail(
|
|
709
|
+
`Adapter target "${target.key}" does not declare testingImport and conformance metadata for scaffolded conformance.`
|
|
710
|
+
);
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const resolveTarget = (
|
|
714
|
+
rootDir: string,
|
|
715
|
+
target: string,
|
|
716
|
+
placement: CreateAdapterPlacement
|
|
717
|
+
): Result<AdapterTargetCatalogEntry, ValidationError> => {
|
|
718
|
+
const catalog = deriveAdapterTargetCatalog(rootDir);
|
|
719
|
+
if (catalog.diagnostics.length > 0) {
|
|
720
|
+
return fail(
|
|
721
|
+
[
|
|
722
|
+
'Adapter target catalog has diagnostics; fix them before scaffolding:',
|
|
723
|
+
...catalog.diagnostics.map((entry) => `- ${entry.message}`),
|
|
724
|
+
].join('\n')
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const entry = catalog.targets.find(
|
|
729
|
+
(candidate) => candidate.target === target
|
|
730
|
+
);
|
|
731
|
+
if (entry === undefined) {
|
|
732
|
+
return fail(`Unknown adapter target "${target}".`);
|
|
733
|
+
}
|
|
734
|
+
if (!entry.placements.includes(placement)) {
|
|
735
|
+
return fail(
|
|
736
|
+
`Adapter target "${entry.key}" does not support ${placement} placement.`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const conformance = assertConformanceScaffold(entry);
|
|
741
|
+
if (conformance.isErr()) {
|
|
742
|
+
return conformance;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const template = assertHttpTemplate(entry);
|
|
746
|
+
if (template.isErr()) {
|
|
747
|
+
return template;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return Result.ok(entry);
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const generateExtractedPackageJson = (
|
|
754
|
+
packageName: string,
|
|
755
|
+
target: AdapterTargetCatalogEntry
|
|
756
|
+
): string =>
|
|
757
|
+
formatJson({
|
|
758
|
+
dependencies: {
|
|
759
|
+
'@ontrails/core': 'workspace:^',
|
|
760
|
+
},
|
|
761
|
+
exports: {
|
|
762
|
+
'.': './src/index.ts',
|
|
763
|
+
'./package.json': './package.json',
|
|
764
|
+
},
|
|
765
|
+
files: [
|
|
766
|
+
'src/**/*.ts',
|
|
767
|
+
'!src/**/__tests__/**',
|
|
768
|
+
'!src/**/*.test.ts',
|
|
769
|
+
'!src/**/*.test-d.ts',
|
|
770
|
+
'README.md',
|
|
771
|
+
],
|
|
772
|
+
name: packageName,
|
|
773
|
+
peerDependencies: {
|
|
774
|
+
[target.ownerPackage]: 'workspace:^',
|
|
775
|
+
},
|
|
776
|
+
scripts: {
|
|
777
|
+
build: 'tsc -b',
|
|
778
|
+
clean: 'rm -rf dist *.tsbuildinfo',
|
|
779
|
+
lint: 'oxlint ./src',
|
|
780
|
+
test: 'bun test',
|
|
781
|
+
typecheck: 'tsc --noEmit',
|
|
782
|
+
},
|
|
783
|
+
trails: {
|
|
784
|
+
adapter: {
|
|
785
|
+
target: target.target,
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
type: 'module',
|
|
789
|
+
version: trailsPackageVersion,
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
const generateExtractedTsconfig = (): string =>
|
|
793
|
+
formatJson({
|
|
794
|
+
compilerOptions: {
|
|
795
|
+
outDir: 'dist',
|
|
796
|
+
rootDir: 'src',
|
|
797
|
+
},
|
|
798
|
+
exclude: ['**/__tests__/**', '**/*.test.ts', 'dist'],
|
|
799
|
+
extends: '../../tsconfig.json',
|
|
800
|
+
include: ['src'],
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
const generateTestTsconfig = (): string =>
|
|
804
|
+
formatJson({
|
|
805
|
+
compilerOptions: {
|
|
806
|
+
noEmit: true,
|
|
807
|
+
rootDir: './src',
|
|
808
|
+
types: ['bun'],
|
|
809
|
+
},
|
|
810
|
+
exclude: [],
|
|
811
|
+
extends: './tsconfig.json',
|
|
812
|
+
include: ['src/**/*.test.ts', 'src/__tests__/**/*.ts'],
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const generateHttpExtractedIndex = (
|
|
816
|
+
target: AdapterTargetCatalogEntry
|
|
817
|
+
): string =>
|
|
818
|
+
`import type { Topo } from '@ontrails/core';
|
|
819
|
+
import { createFetchHandler } from ${literal(target.ownerPackage)};
|
|
820
|
+
import type { CreateFetchHandlerOptions } from ${literal(target.ownerPackage)};
|
|
821
|
+
|
|
822
|
+
export interface CreateAppOptions extends CreateFetchHandlerOptions {}
|
|
823
|
+
|
|
824
|
+
export const createApp = (
|
|
825
|
+
graph: Topo,
|
|
826
|
+
options: CreateAppOptions = {}
|
|
827
|
+
) => ({
|
|
828
|
+
fetch: createFetchHandler(graph, options),
|
|
829
|
+
});
|
|
830
|
+
`;
|
|
831
|
+
|
|
832
|
+
const generateConformanceTest = (
|
|
833
|
+
target: AdapterTargetCatalogEntry,
|
|
834
|
+
adapterImport: string,
|
|
835
|
+
createAppImportPath: string
|
|
836
|
+
): string => {
|
|
837
|
+
const conformance = target.conformance as AdapterTargetConformanceManifest;
|
|
838
|
+
return `import {
|
|
839
|
+
${conformance.casesFactory},
|
|
840
|
+
${conformance.runner},
|
|
841
|
+
} from ${literal(target.testingImport ?? '')};
|
|
842
|
+
import type { ${conformance.adapterType} } from ${literal(target.testingImport ?? '')};
|
|
843
|
+
|
|
844
|
+
import { createApp } from ${literal(createAppImportPath)};
|
|
845
|
+
|
|
846
|
+
const adapter = {
|
|
847
|
+
createApp,
|
|
848
|
+
name: ${literal(adapterImport)},
|
|
849
|
+
} satisfies ${conformance.adapterType};
|
|
850
|
+
|
|
851
|
+
await ${conformance.runner}(adapter, await ${conformance.casesFactory}());
|
|
852
|
+
`;
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const generateReadme = (
|
|
856
|
+
packageName: string,
|
|
857
|
+
target: AdapterTargetCatalogEntry
|
|
858
|
+
): string =>
|
|
859
|
+
`# ${packageName}
|
|
860
|
+
|
|
861
|
+
${packageName} is a Trails ${target.target} adapter scaffold.
|
|
862
|
+
|
|
863
|
+
## Validate
|
|
864
|
+
|
|
865
|
+
\`\`\`bash
|
|
866
|
+
bun test
|
|
867
|
+
bun run typecheck
|
|
868
|
+
bun run lint
|
|
869
|
+
trails adapter check --root-dir ../..
|
|
870
|
+
\`\`\`
|
|
871
|
+
|
|
872
|
+
The conformance test imports ${target.testingImport} so owner-authored cases stay current as ${target.ownerPackage} evolves.
|
|
873
|
+
`;
|
|
874
|
+
|
|
875
|
+
const buildExtractedPlan = (
|
|
876
|
+
rootDir: string,
|
|
877
|
+
input: CreateAdapterInput,
|
|
878
|
+
target: AdapterTargetCatalogEntry
|
|
879
|
+
): Result<AdapterOperationPlan, Error> => {
|
|
880
|
+
const packageName =
|
|
881
|
+
input.packageName ?? defaultExtractedPackageName(input.name);
|
|
882
|
+
if (!packageNamePattern.test(packageName)) {
|
|
883
|
+
return fail(packageNameMessage);
|
|
884
|
+
}
|
|
885
|
+
const existingPackage = findWorkspacePackageName(rootDir, packageName);
|
|
886
|
+
if (existingPackage) {
|
|
887
|
+
return fail(
|
|
888
|
+
`Workspace package name "${existingPackage.name}" already exists at ${existingPackage.workspacePath}.`
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const packageRootPath = `adapters/${input.name}`;
|
|
893
|
+
if (!rootWorkspaceIncludesPath(rootDir, packageRootPath)) {
|
|
894
|
+
return fail(
|
|
895
|
+
`Root package.json workspaces must include "${packageRootPath}" or "adapters/*" before create.adapter can write an extracted adapter package.`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const packageRoot = resolveProjectPath(rootDir, packageRootPath);
|
|
900
|
+
if (packageRoot.isErr()) {
|
|
901
|
+
return packageRoot;
|
|
902
|
+
}
|
|
903
|
+
if (existsSync(packageRoot.value)) {
|
|
904
|
+
return fail(`Adapter package already exists at ${packageRootPath}.`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const adapterImport = packageName;
|
|
908
|
+
const operations = [
|
|
909
|
+
writeOperation(
|
|
910
|
+
`${packageRootPath}/package.json`,
|
|
911
|
+
generateExtractedPackageJson(packageName, target)
|
|
912
|
+
),
|
|
913
|
+
writeOperation(
|
|
914
|
+
`${packageRootPath}/tsconfig.json`,
|
|
915
|
+
generateExtractedTsconfig()
|
|
916
|
+
),
|
|
917
|
+
writeOperation(
|
|
918
|
+
`${packageRootPath}/tsconfig.tests.json`,
|
|
919
|
+
generateTestTsconfig()
|
|
920
|
+
),
|
|
921
|
+
writeOperation(
|
|
922
|
+
`${packageRootPath}/README.md`,
|
|
923
|
+
generateReadme(packageName, target)
|
|
924
|
+
),
|
|
925
|
+
writeOperation(
|
|
926
|
+
`${packageRootPath}/src/index.ts`,
|
|
927
|
+
generateHttpExtractedIndex(target)
|
|
928
|
+
),
|
|
929
|
+
writeOperation(
|
|
930
|
+
`${packageRootPath}/src/__tests__/conformance.test.ts`,
|
|
931
|
+
generateConformanceTest(target, adapterImport, '../index.js')
|
|
932
|
+
),
|
|
933
|
+
];
|
|
934
|
+
|
|
935
|
+
return Result.ok({
|
|
936
|
+
adapterImport,
|
|
937
|
+
operations,
|
|
938
|
+
packageName,
|
|
939
|
+
targetKey: target.key,
|
|
940
|
+
});
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const buildSubpathPlan = (
|
|
944
|
+
_rootDir: string,
|
|
945
|
+
_input: CreateAdapterInput,
|
|
946
|
+
_target: AdapterTargetCatalogEntry
|
|
947
|
+
): Result<AdapterOperationPlan, Error> =>
|
|
948
|
+
fail(
|
|
949
|
+
'Subpath adapter scaffolding is deferred until shared adapter checks discover subpath adapter subjects.'
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
const buildOperationPlan = (
|
|
953
|
+
rootDir: string,
|
|
954
|
+
input: CreateAdapterInput,
|
|
955
|
+
target: AdapterTargetCatalogEntry
|
|
956
|
+
): Result<AdapterOperationPlan, Error> =>
|
|
957
|
+
input.placement === 'extracted'
|
|
958
|
+
? buildExtractedPlan(rootDir, input, target)
|
|
959
|
+
: buildSubpathPlan(rootDir, input, target);
|
|
960
|
+
|
|
961
|
+
const runPlannedOperations = async (
|
|
962
|
+
rootDir: string,
|
|
963
|
+
operations: readonly ProjectWriteOperation[],
|
|
964
|
+
dryRun: boolean
|
|
965
|
+
): Promise<Result<readonly PlannedProjectOperation[], Error>> =>
|
|
966
|
+
dryRun
|
|
967
|
+
? planProjectOperations(rootDir, operations)
|
|
968
|
+
: await applyProjectOperations(rootDir, operations);
|
|
969
|
+
|
|
970
|
+
export const createAdapterTrail = trail('create.adapter', {
|
|
971
|
+
args: ['name'],
|
|
972
|
+
blaze: async (input: CreateAdapterInput, ctx) => {
|
|
973
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
974
|
+
if (rootDirResult.isErr()) {
|
|
975
|
+
return rootDirResult;
|
|
976
|
+
}
|
|
977
|
+
const physicalRootDir = resolvePhysicalRootDir(rootDirResult.value);
|
|
978
|
+
if (physicalRootDir.isErr()) {
|
|
979
|
+
return physicalRootDir;
|
|
980
|
+
}
|
|
981
|
+
const rootDir = physicalRootDir.value;
|
|
982
|
+
const target = resolveTarget(rootDir, input.target, input.placement);
|
|
983
|
+
if (target.isErr()) {
|
|
984
|
+
return target;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const plan = buildOperationPlan(rootDir, input, target.value);
|
|
988
|
+
if (plan.isErr()) {
|
|
989
|
+
return plan;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const plannedOperations = await runPlannedOperations(
|
|
993
|
+
rootDir,
|
|
994
|
+
plan.value.operations,
|
|
995
|
+
input.dryRun
|
|
996
|
+
);
|
|
997
|
+
if (plannedOperations.isErr()) {
|
|
998
|
+
return plannedOperations;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const report = checkAdapters(rootDir);
|
|
1002
|
+
const created = input.dryRun
|
|
1003
|
+
? []
|
|
1004
|
+
: plannedOperations.value
|
|
1005
|
+
.filter((operation) => operation.kind === 'write')
|
|
1006
|
+
.map((operation) => operation.path);
|
|
1007
|
+
|
|
1008
|
+
return Result.ok({
|
|
1009
|
+
adapterImport: plan.value.adapterImport,
|
|
1010
|
+
created,
|
|
1011
|
+
diagnostics: [...report.diagnostics],
|
|
1012
|
+
dryRun: input.dryRun,
|
|
1013
|
+
packageName: plan.value.packageName,
|
|
1014
|
+
placement: 'extracted',
|
|
1015
|
+
plannedOperations: [...plannedOperations.value],
|
|
1016
|
+
targetKey: plan.value.targetKey,
|
|
1017
|
+
} satisfies CreateAdapterResult);
|
|
1018
|
+
},
|
|
1019
|
+
description: 'Scaffold an adapter package from adapter target catalog facts',
|
|
1020
|
+
fields: {
|
|
1021
|
+
placement: {
|
|
1022
|
+
options: [
|
|
1023
|
+
{
|
|
1024
|
+
hint: 'Standalone package under adapters/',
|
|
1025
|
+
label: 'Extracted',
|
|
1026
|
+
value: 'extracted',
|
|
1027
|
+
},
|
|
1028
|
+
],
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
input: z.object({
|
|
1032
|
+
dryRun: z
|
|
1033
|
+
.boolean()
|
|
1034
|
+
.default(false)
|
|
1035
|
+
.describe('Plan adapter scaffold writes without touching disk'),
|
|
1036
|
+
name: z
|
|
1037
|
+
.string()
|
|
1038
|
+
.regex(adapterNamePattern, adapterNameMessage)
|
|
1039
|
+
.describe('Adapter name, e.g. hono'),
|
|
1040
|
+
packageName: z
|
|
1041
|
+
.string()
|
|
1042
|
+
.regex(packageNamePattern, packageNameMessage)
|
|
1043
|
+
.optional()
|
|
1044
|
+
.describe('Package name for extracted adapter placement'),
|
|
1045
|
+
placement: z
|
|
1046
|
+
.enum(createAdapterPlacements)
|
|
1047
|
+
.default('extracted')
|
|
1048
|
+
.describe('Adapter placement'),
|
|
1049
|
+
rootDir: z.string().optional().describe('Workspace root directory'),
|
|
1050
|
+
target: z.string().describe('Adapter target id, e.g. http'),
|
|
1051
|
+
}),
|
|
1052
|
+
intent: 'write',
|
|
1053
|
+
output: z.object({
|
|
1054
|
+
adapterImport: z.string(),
|
|
1055
|
+
created: z.array(z.string()).readonly(),
|
|
1056
|
+
diagnostics: z.array(
|
|
1057
|
+
z.object({
|
|
1058
|
+
code: z.string(),
|
|
1059
|
+
message: z.string(),
|
|
1060
|
+
packageJsonPath: z.string(),
|
|
1061
|
+
packageName: z.string().optional(),
|
|
1062
|
+
placement: z.enum(adapterTargetPlacements).optional(),
|
|
1063
|
+
severity: z.enum(['error', 'warn']),
|
|
1064
|
+
target: z.string().optional(),
|
|
1065
|
+
})
|
|
1066
|
+
),
|
|
1067
|
+
dryRun: z.boolean(),
|
|
1068
|
+
packageName: z.string(),
|
|
1069
|
+
placement: z.enum(createAdapterPlacements),
|
|
1070
|
+
plannedOperations: z.array(
|
|
1071
|
+
z.discriminatedUnion('kind', [
|
|
1072
|
+
z.object({ kind: z.literal('mkdir'), path: z.string() }),
|
|
1073
|
+
z.object({
|
|
1074
|
+
from: z.string(),
|
|
1075
|
+
kind: z.literal('rename'),
|
|
1076
|
+
to: z.string(),
|
|
1077
|
+
}),
|
|
1078
|
+
z.object({ kind: z.literal('write'), path: z.string() }),
|
|
1079
|
+
])
|
|
1080
|
+
),
|
|
1081
|
+
targetKey: z.string(),
|
|
1082
|
+
}),
|
|
1083
|
+
permit: { scopes: ['project:write'] },
|
|
1084
|
+
});
|