@ankhorage/devtools 1.9.5 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tools/skills/assets/ankhorage-project-structure/references/skill-distribution.md +5 -0
- package/dist/tools/skills/assets/zora-designer/SKILL.md +89 -0
- package/dist/tools/skills/assets/zora-designer/agents/openai.yaml +6 -0
- package/dist/tools/skills/assets/zora-designer/assets/audit-rubric.json +312 -0
- package/dist/tools/skills/assets/zora-designer/references/artifact.md +93 -0
- package/dist/tools/skills/assets/zora-designer/references/audit.md +100 -0
- package/dist/tools/skills/assets/zora-designer/references/workflow.md +137 -0
- package/dist/tools/skills/assets/zora-designer/scripts/audit.mjs +573 -0
- package/dist/tools/skills/assets/zora-designer/scripts/owner-api.mjs +440 -0
- package/dist/tools/skills/assets/zora-designer/scripts/scaffold-template.mjs +222 -0
- package/dist/tools/skills/managed.js +10 -9
- package/dist/tools/skills/selection.d.ts +7 -0
- package/dist/tools/skills/selection.js +82 -0
- package/package.json +1 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, parse, resolve } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const OWNER_RELEASES = {
|
|
8
|
+
templates: { packageName: '@ankhorage/templates', minimumVersion: '8.0.0' },
|
|
9
|
+
zora: { packageName: '@ankhorage/zora', minimumVersion: '4.0.0' },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const OWNER_REQUIREMENTS = {
|
|
13
|
+
templates: {
|
|
14
|
+
...OWNER_RELEASES.templates,
|
|
15
|
+
specifier: '@ankhorage/templates',
|
|
16
|
+
exports: [
|
|
17
|
+
'CATEGORY_PRESETS',
|
|
18
|
+
'TONE_PAIR_CATALOG',
|
|
19
|
+
'resolveTonePair',
|
|
20
|
+
'resolveCategoryDesignPreset',
|
|
21
|
+
'compileCategoryDesign',
|
|
22
|
+
'composeCategoryAppManifest',
|
|
23
|
+
'validateTemplateManifest',
|
|
24
|
+
'assertTemplateManifestReady',
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
zoraTheme: {
|
|
28
|
+
...OWNER_RELEASES.zora,
|
|
29
|
+
specifier: '@ankhorage/zora/theme',
|
|
30
|
+
exports: ['compileZoraTheme'],
|
|
31
|
+
},
|
|
32
|
+
zoraMetadata: {
|
|
33
|
+
...OWNER_RELEASES.zora,
|
|
34
|
+
specifier: '@ankhorage/zora/metadata',
|
|
35
|
+
exports: ['ZORA_COMPONENT_META', 'ZORA_THEME_RECIPE_META'],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/*** Load and validate every released owner API from one target repository. */
|
|
40
|
+
export async function loadOwnerApis(targetDirectory = process.cwd()) {
|
|
41
|
+
const loaded = {};
|
|
42
|
+
|
|
43
|
+
for (const [ownerKey, requirement] of Object.entries(OWNER_REQUIREMENTS)) {
|
|
44
|
+
loaded[ownerKey] = await loadOwnerModule(targetDirectory, requirement);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
templates: loaded.templates.module,
|
|
49
|
+
zoraTheme: loaded.zoraTheme.module,
|
|
50
|
+
zoraMetadata: loaded.zoraMetadata.module,
|
|
51
|
+
versions: {
|
|
52
|
+
templates: loaded.templates.version,
|
|
53
|
+
zora: loaded.zoraTheme.version,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/*** Return installed catalogs and metadata names without copying owner definitions. */
|
|
59
|
+
export async function inspectOwnerApis(targetDirectory = process.cwd()) {
|
|
60
|
+
const owners = await loadOwnerApis(targetDirectory);
|
|
61
|
+
return {
|
|
62
|
+
versions: owners.versions,
|
|
63
|
+
categories: Object.values(owners.templates.CATEGORY_PRESETS).map((preset) => ({
|
|
64
|
+
category: preset.category,
|
|
65
|
+
label: preset.label,
|
|
66
|
+
recommendedPrimaryColors: preset.recommendedPrimaryColors,
|
|
67
|
+
recommendedHarmonies: preset.recommendedHarmonies,
|
|
68
|
+
tonePairs: preset.tonePairs,
|
|
69
|
+
})),
|
|
70
|
+
tonePairs: owners.templates.TONE_PAIR_CATALOG,
|
|
71
|
+
components: Object.keys(owners.zoraMetadata.ZORA_COMPONENT_META).sort(),
|
|
72
|
+
themeRecipes: Object.keys(owners.zoraMetadata.ZORA_THEME_RECIPE_META).sort(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/*** Compile category design, validate region nodes, and compose one canonical draft or release manifest. */
|
|
77
|
+
export async function composeDesign(input, targetDirectory = process.cwd()) {
|
|
78
|
+
const owners = await loadOwnerApis(targetDirectory);
|
|
79
|
+
assertRecord(input, 'Design input');
|
|
80
|
+
assertNonEmptyString(input.category, 'category');
|
|
81
|
+
assertRecord(input.navigator, 'navigator');
|
|
82
|
+
assertRecord(input.screens, 'screens');
|
|
83
|
+
assertSupportedThemeRecipes(input.theme?.recipes, owners.zoraMetadata.ZORA_THEME_RECIPE_META);
|
|
84
|
+
|
|
85
|
+
const regionResult = resolveRegionNodes(
|
|
86
|
+
input.screens,
|
|
87
|
+
Array.isArray(input.regions) ? input.regions : [],
|
|
88
|
+
owners.zoraMetadata.ZORA_COMPONENT_META,
|
|
89
|
+
);
|
|
90
|
+
const design = owners.templates.compileCategoryDesign(input.category, input.theme ?? {});
|
|
91
|
+
const { computedTheme, ...resolvedDesign } = design;
|
|
92
|
+
const requestedAuthoringState = input.authoringState === 'release' ? 'release' : 'draft';
|
|
93
|
+
const authoringState = regionResult.gaps.length === 0 ? requestedAuthoringState : 'draft';
|
|
94
|
+
const composition = owners.templates.composeCategoryAppManifest({
|
|
95
|
+
category: input.category,
|
|
96
|
+
name: input.name,
|
|
97
|
+
slug: input.slug,
|
|
98
|
+
version: input.version,
|
|
99
|
+
navigator: input.navigator,
|
|
100
|
+
screens: regionResult.screens,
|
|
101
|
+
dataSources: input.dataSources,
|
|
102
|
+
dataBindings: input.dataBindings,
|
|
103
|
+
modules: input.modules,
|
|
104
|
+
modulesConfig: input.modulesConfig,
|
|
105
|
+
theme: input.theme,
|
|
106
|
+
authoringState,
|
|
107
|
+
});
|
|
108
|
+
const ownerDiagnostics = [
|
|
109
|
+
...design.diagnostics,
|
|
110
|
+
...computedTheme.diagnostics,
|
|
111
|
+
...composition.diagnostics,
|
|
112
|
+
];
|
|
113
|
+
const blocked = regionResult.gaps.length > 0 || composition.status === 'blocked';
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
owners: owners.versions,
|
|
117
|
+
design: resolvedDesign,
|
|
118
|
+
computedTheme,
|
|
119
|
+
composition,
|
|
120
|
+
regionDiagnostics: regionResult.diagnostics,
|
|
121
|
+
ownerDiagnostics,
|
|
122
|
+
requestedAuthoringState,
|
|
123
|
+
applicationGate: blocked ? 'blocked' : 'pass',
|
|
124
|
+
blockers: regionResult.gaps,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/*** Resolve explicit region-to-component decisions and insert validated nodes into cloned screens. */
|
|
129
|
+
export function resolveRegionNodes(screens, regions, componentMeta) {
|
|
130
|
+
const resolvedScreens = structuredClone(screens);
|
|
131
|
+
const diagnostics = [];
|
|
132
|
+
const gaps = [];
|
|
133
|
+
|
|
134
|
+
for (const region of regions) {
|
|
135
|
+
assertRecord(region, 'Region');
|
|
136
|
+
assertNonEmptyString(region.id, 'region.id');
|
|
137
|
+
assertNonEmptyString(region.screenId, 'region.screenId');
|
|
138
|
+
assertNonEmptyString(region.requestedCapability, 'region.requestedCapability');
|
|
139
|
+
const screen = resolvedScreens[region.screenId];
|
|
140
|
+
if (!screen) {
|
|
141
|
+
throw new Error(`Region "${region.id}" targets unknown screen "${region.screenId}".`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const resolution = resolveRegionNode(region, componentMeta);
|
|
145
|
+
insertRegionNode(screen, region.parentNodeId, resolution.node, componentMeta);
|
|
146
|
+
diagnostics.push(resolution.diagnostic);
|
|
147
|
+
if (resolution.gap !== null) {
|
|
148
|
+
gaps.push(resolution.gap);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { screens: resolvedScreens, diagnostics, gaps };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/*** Resolve one exact metadata-backed component or the owner-defined MissingElement placeholder. */
|
|
156
|
+
function resolveRegionNode(region, componentMeta) {
|
|
157
|
+
const component = typeof region.component === 'string' ? region.component : null;
|
|
158
|
+
const meta = component === null ? null : componentMeta[component];
|
|
159
|
+
if (meta && meta.directManifestNode && meta.manifestPolicy?.kind !== 'unresolved-element') {
|
|
160
|
+
const props = assertSupportedProps(region.props ?? {}, meta, region.id);
|
|
161
|
+
return {
|
|
162
|
+
node: { id: region.id, type: meta.name, props },
|
|
163
|
+
diagnostic: {
|
|
164
|
+
regionId: region.id,
|
|
165
|
+
status: 'matched',
|
|
166
|
+
component: meta.name,
|
|
167
|
+
evidenceId: region.evidenceId ?? null,
|
|
168
|
+
},
|
|
169
|
+
gap: null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const missingMeta = componentMeta.MissingElement;
|
|
174
|
+
if (!missingMeta || missingMeta.manifestPolicy?.kind !== 'unresolved-element') {
|
|
175
|
+
throw new Error(
|
|
176
|
+
'Installed @ankhorage/zora metadata does not expose the canonical MissingElement contract.',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const props = {
|
|
180
|
+
...(missingMeta.blueprint?.defaultProps ?? {}),
|
|
181
|
+
requestedCapability: region.requestedCapability,
|
|
182
|
+
reason:
|
|
183
|
+
typeof region.reason === 'string' && region.reason.trim() !== ''
|
|
184
|
+
? region.reason
|
|
185
|
+
: `No exact metadata-supported ZORA element was selected for region "${region.id}".`,
|
|
186
|
+
...(typeof region.evidenceId === 'string' ? { evidenceId: region.evidenceId } : {}),
|
|
187
|
+
};
|
|
188
|
+
assertSupportedProps(props, missingMeta, region.id);
|
|
189
|
+
const gap = {
|
|
190
|
+
id: `missing-element:${region.id}`,
|
|
191
|
+
scope: 'composition',
|
|
192
|
+
owner: '@ankhorage/zora',
|
|
193
|
+
regionId: region.id,
|
|
194
|
+
requestedCapability: region.requestedCapability,
|
|
195
|
+
evidenceId: region.evidenceId ?? null,
|
|
196
|
+
ownerIssueUrl: region.ownerIssueUrl ?? null,
|
|
197
|
+
reason: props.reason,
|
|
198
|
+
unblockCondition: 'Replace MissingElement with a released exact ZORA element and revalidate.',
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
node: { id: region.id, type: missingMeta.name, props },
|
|
202
|
+
diagnostic: { regionId: region.id, status: 'missing', component: missingMeta.name, ...gap },
|
|
203
|
+
gap,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/*** Validate that manifest props are declared by the selected component metadata. */
|
|
208
|
+
function assertSupportedProps(props, meta, regionId) {
|
|
209
|
+
assertRecord(props, `props for region "${regionId}"`);
|
|
210
|
+
const unsupported = Object.keys(props).filter((name) => !(name in meta.props));
|
|
211
|
+
if (unsupported.length > 0) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Region "${regionId}" uses props absent from ZORA metadata for ${meta.name}: ${unsupported.join(', ')}.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return props;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/*** Insert a region node under the declared parent or the target screen root. */
|
|
220
|
+
function insertRegionNode(screen, parentNodeId, node, componentMeta) {
|
|
221
|
+
const parent =
|
|
222
|
+
typeof parentNodeId === 'string' && parentNodeId !== ''
|
|
223
|
+
? findNode(screen.root, parentNodeId)
|
|
224
|
+
: screen.root;
|
|
225
|
+
if (!parent) {
|
|
226
|
+
throw new Error(`Region parent node not found: ${String(parentNodeId)}`);
|
|
227
|
+
}
|
|
228
|
+
const parentMeta = componentMeta[parent.type];
|
|
229
|
+
if (!parentMeta || !parentMeta.directManifestNode) {
|
|
230
|
+
throw new Error(`Region parent "${parent.id}" is absent from direct ZORA manifest metadata.`);
|
|
231
|
+
}
|
|
232
|
+
if (!parentMeta.allowedChildren.includes(node.type)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`ZORA metadata does not allow ${node.type} under ${parent.type} for parent "${parent.id}".`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
parent.children = [...(Array.isArray(parent.children) ? parent.children : []), node];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/*** Validate persisted component and pattern recipe overrides against exact ZORA metadata fields. */
|
|
241
|
+
function assertSupportedThemeRecipes(recipes, recipeMeta) {
|
|
242
|
+
if (recipes === undefined) return;
|
|
243
|
+
assertRecord(recipes, 'theme.recipes');
|
|
244
|
+
for (const [group, kind] of [
|
|
245
|
+
['components', 'component'],
|
|
246
|
+
['patterns', 'pattern'],
|
|
247
|
+
]) {
|
|
248
|
+
const overrides = recipes[group];
|
|
249
|
+
if (overrides === undefined) continue;
|
|
250
|
+
assertRecord(overrides, `theme.recipes.${group}`);
|
|
251
|
+
for (const [recipeName, fields] of Object.entries(overrides)) {
|
|
252
|
+
const meta = recipeMeta[recipeName];
|
|
253
|
+
if (!meta || meta.kind !== kind) {
|
|
254
|
+
throw new Error(`Unknown ZORA ${kind} theme recipe: ${recipeName}.`);
|
|
255
|
+
}
|
|
256
|
+
assertRecord(fields, `theme recipe ${recipeName}`);
|
|
257
|
+
for (const [fieldName, value] of Object.entries(fields)) {
|
|
258
|
+
const fieldMeta = meta.fields[fieldName];
|
|
259
|
+
if (!fieldMeta || !isRecipeValueSupported(value, fieldMeta)) {
|
|
260
|
+
throw new Error(`Unsupported ZORA theme recipe value: ${recipeName}.${fieldName}.`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/*** Validate one recipe value using its owner-defined boolean, choice, or token field metadata. */
|
|
268
|
+
function isRecipeValueSupported(value, fieldMeta) {
|
|
269
|
+
if (fieldMeta.type === 'boolean') return typeof value === 'boolean';
|
|
270
|
+
if (typeof value !== 'string') return false;
|
|
271
|
+
return fieldMeta.type !== 'choice' || fieldMeta.options.includes(value);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/*** Find a manifest node recursively by stable node ID. */
|
|
275
|
+
function findNode(node, nodeId) {
|
|
276
|
+
if (node.id === nodeId) return node;
|
|
277
|
+
for (const child of Array.isArray(node.children) ? node.children : []) {
|
|
278
|
+
const found = findNode(child, nodeId);
|
|
279
|
+
if (found) return found;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/*** Resolve, version-check, and import one public owner module from the target repository. */
|
|
285
|
+
async function loadOwnerModule(targetDirectory, requirement) {
|
|
286
|
+
let packageManifestPath;
|
|
287
|
+
try {
|
|
288
|
+
packageManifestPath = await findInstalledPackageManifest(
|
|
289
|
+
targetDirectory,
|
|
290
|
+
requirement.packageName,
|
|
291
|
+
);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
throw ownerError(
|
|
294
|
+
requirement,
|
|
295
|
+
'is not installed or does not export the required public subpath',
|
|
296
|
+
error,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const packageManifest = JSON.parse(await readFile(packageManifestPath, 'utf8'));
|
|
301
|
+
const version = packageManifest.version;
|
|
302
|
+
if (typeof version !== 'string' || compareVersions(version, requirement.minimumVersion) < 0) {
|
|
303
|
+
throw ownerError(
|
|
304
|
+
requirement,
|
|
305
|
+
`is outdated (found ${String(version)}, requires >=${requirement.minimumVersion})`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const modulePath = resolvePublicExportPath(
|
|
309
|
+
packageManifest,
|
|
310
|
+
packageManifestPath,
|
|
311
|
+
requirement.specifier,
|
|
312
|
+
requirement,
|
|
313
|
+
);
|
|
314
|
+
const ownerModule = await import(pathToFileURL(modulePath).href);
|
|
315
|
+
const missingExports = requirement.exports.filter((name) => !(name in ownerModule));
|
|
316
|
+
if (missingExports.length > 0) {
|
|
317
|
+
throw ownerError(requirement, `is missing public exports: ${missingExports.join(', ')}`);
|
|
318
|
+
}
|
|
319
|
+
return { module: ownerModule, version };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/*** Find the nearest installed package manifest without falling back to the skill's own tree. */
|
|
323
|
+
async function findInstalledPackageManifest(targetDirectory, packageName) {
|
|
324
|
+
const resolvedTarget = resolve(targetDirectory);
|
|
325
|
+
const selfManifestPath = join(resolvedTarget, 'package.json');
|
|
326
|
+
try {
|
|
327
|
+
const selfManifest = JSON.parse(await readFile(selfManifestPath, 'utf8'));
|
|
328
|
+
if (selfManifest.name === packageName) return selfManifestPath;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const filesystemRoot = parse(resolvedTarget).root;
|
|
334
|
+
let directory = resolvedTarget;
|
|
335
|
+
while (true) {
|
|
336
|
+
const candidate = join(directory, 'node_modules', ...packageName.split('/'), 'package.json');
|
|
337
|
+
try {
|
|
338
|
+
await readFile(candidate, 'utf8');
|
|
339
|
+
return candidate;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
|
342
|
+
}
|
|
343
|
+
if (directory === filesystemRoot) break;
|
|
344
|
+
directory = dirname(directory);
|
|
345
|
+
}
|
|
346
|
+
throw new Error(`Package not installed from target root: ${packageName}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/*** Resolve one import-condition public export from an installed package manifest. */
|
|
350
|
+
function resolvePublicExportPath(packageManifest, packageManifestPath, specifier, requirement) {
|
|
351
|
+
const exportKey =
|
|
352
|
+
specifier === requirement.packageName
|
|
353
|
+
? '.'
|
|
354
|
+
: `./${specifier.slice(requirement.packageName.length + 1)}`;
|
|
355
|
+
const exportsField = packageManifest.exports;
|
|
356
|
+
const rawExport = isRecord(exportsField) ? exportsField[exportKey] : null;
|
|
357
|
+
const exportTarget = selectImportExportTarget(rawExport);
|
|
358
|
+
if (exportTarget === null || !exportTarget.startsWith('./')) {
|
|
359
|
+
throw ownerError(requirement, `does not expose the import target for ${specifier}`);
|
|
360
|
+
}
|
|
361
|
+
return resolve(dirname(packageManifestPath), exportTarget);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/*** Select the canonical ESM import target without resolving a CommonJS compatibility condition. */
|
|
365
|
+
function selectImportExportTarget(rawExport) {
|
|
366
|
+
if (typeof rawExport === 'string') return rawExport;
|
|
367
|
+
if (!isRecord(rawExport)) return null;
|
|
368
|
+
for (const condition of ['import', 'default', 'bun', 'browser', 'react-native']) {
|
|
369
|
+
if (typeof rawExport[condition] === 'string') return rawExport[condition];
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/*** Create an actionable released-owner diagnostic without offering a compatibility fallback. */
|
|
375
|
+
function ownerError(requirement, detail, cause) {
|
|
376
|
+
return new Error(
|
|
377
|
+
`zora-designer requires ${requirement.packageName} >=${requirement.minimumVersion}; ${detail}. ` +
|
|
378
|
+
`Update the target dependency through its normal Renovate/release workflow and rerun inspection.`,
|
|
379
|
+
cause === undefined ? undefined : { cause },
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/*** Compare stable semantic versions needed by the released public API gates. */
|
|
384
|
+
function compareVersions(left, right) {
|
|
385
|
+
const leftParts = parseVersion(left);
|
|
386
|
+
const rightParts = parseVersion(right);
|
|
387
|
+
for (let index = 0; index < 3; index += 1) {
|
|
388
|
+
const difference = leftParts[index] - rightParts[index];
|
|
389
|
+
if (difference !== 0) return difference;
|
|
390
|
+
}
|
|
391
|
+
return 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/*** Parse the numeric major, minor, and patch tuple from a semantic version. */
|
|
395
|
+
function parseVersion(version) {
|
|
396
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/u.exec(version);
|
|
397
|
+
if (!match) return [-1, -1, -1];
|
|
398
|
+
return match.slice(1, 4).map(Number);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/*** Require an object-shaped input value. */
|
|
402
|
+
function assertRecord(value, label) {
|
|
403
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
404
|
+
throw new Error(`${label} must be an object.`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/*** Narrow unknown package export metadata to a record. */
|
|
409
|
+
function isRecord(value) {
|
|
410
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/*** Require a non-empty string input field. */
|
|
414
|
+
function assertNonEmptyString(value, label) {
|
|
415
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
416
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/*** Run the portable command-line interface when this file is executed directly. */
|
|
421
|
+
async function main() {
|
|
422
|
+
const [command, inputPath] = process.argv.slice(2);
|
|
423
|
+
if (command === 'inspect') {
|
|
424
|
+
console.log(JSON.stringify(await inspectOwnerApis(), null, 2));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (command === 'compose' && inputPath) {
|
|
428
|
+
const input = JSON.parse(await readFile(inputPath, 'utf8'));
|
|
429
|
+
console.log(JSON.stringify(await composeDesign(input), null, 2));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
throw new Error('Usage: owner-api.mjs inspect | owner-api.mjs compose <input.json>');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
436
|
+
main().catch((error) => {
|
|
437
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
438
|
+
process.exitCode = 1;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { loadOwnerApis } from './owner-api.mjs';
|
|
8
|
+
|
|
9
|
+
/*** Validate and scaffold one ready authored manifest into the normal Templates variant layout. */
|
|
10
|
+
export async function scaffoldTemplate(input) {
|
|
11
|
+
assertRecord(input, 'Scaffold input');
|
|
12
|
+
for (const field of ['targetDirectory', 'category', 'templateId', 'label', 'description']) {
|
|
13
|
+
assertNonEmptyString(input[field], field);
|
|
14
|
+
}
|
|
15
|
+
if (
|
|
16
|
+
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(input.templateId) ||
|
|
17
|
+
['default', 'starter'].includes(input.templateId)
|
|
18
|
+
) {
|
|
19
|
+
throw new Error('templateId must be a non-reserved kebab-case identifier.');
|
|
20
|
+
}
|
|
21
|
+
assertRecord(input.manifest, 'manifest');
|
|
22
|
+
|
|
23
|
+
const targetDirectory = resolve(input.targetDirectory);
|
|
24
|
+
const packageManifest = JSON.parse(await readFile(join(targetDirectory, 'package.json'), 'utf8'));
|
|
25
|
+
if (packageManifest.name !== '@ankhorage/templates') {
|
|
26
|
+
throw new Error(
|
|
27
|
+
'Template scaffolding is available only in the @ankhorage/templates repository.',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (input.manifest.metadata?.category !== input.category) {
|
|
31
|
+
throw new Error('Scaffold category must match manifest.metadata.category.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const owners = await loadOwnerApis(targetDirectory);
|
|
35
|
+
const composition = owners.templates.validateTemplateManifest(input.manifest, 'release');
|
|
36
|
+
if (composition.status !== 'ready') {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Manifest is not release-ready: ${composition.diagnostics.map((item) => item.message).join('; ')}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const manifest = owners.templates.assertTemplateManifestReady(composition);
|
|
42
|
+
const categoryDirectoryName = input.category.replaceAll('_', '-');
|
|
43
|
+
const categoryDirectory = resolve(
|
|
44
|
+
targetDirectory,
|
|
45
|
+
'src/templates/starter/categories',
|
|
46
|
+
categoryDirectoryName,
|
|
47
|
+
);
|
|
48
|
+
assertInside(targetDirectory, categoryDirectory);
|
|
49
|
+
const variantDirectory = resolve(categoryDirectory, input.templateId);
|
|
50
|
+
assertInside(categoryDirectory, variantDirectory);
|
|
51
|
+
if (await pathExists(variantDirectory)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Template source already exists: ${relative(targetDirectory, variantDirectory)}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const registryPath = join(categoryDirectory, 'index.ts');
|
|
58
|
+
const registrySource = await readFile(registryPath, 'utf8');
|
|
59
|
+
const symbol = toPascalCase(input.templateId);
|
|
60
|
+
const factoryBase = symbol.endsWith('Starter') ? symbol.slice(0, -'Starter'.length) : symbol;
|
|
61
|
+
const factoryName = `create${factoryBase}StarterTemplate`;
|
|
62
|
+
const manifestName = `AUTHORED_${toConstantCase(input.templateId)}_MANIFEST`;
|
|
63
|
+
const registrySourceUpdated = updateCategoryRegistry(registrySource, {
|
|
64
|
+
templateId: input.templateId,
|
|
65
|
+
label: input.label,
|
|
66
|
+
description: input.description,
|
|
67
|
+
factoryName,
|
|
68
|
+
});
|
|
69
|
+
const files = createTemplateFiles({ manifest, manifestName, factoryName });
|
|
70
|
+
|
|
71
|
+
await mkdir(variantDirectory, { recursive: true });
|
|
72
|
+
for (const [fileName, contents] of Object.entries(files)) {
|
|
73
|
+
await writeFile(join(variantDirectory, fileName), contents);
|
|
74
|
+
}
|
|
75
|
+
await writeFile(registryPath, registrySourceUpdated);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
targetDirectory,
|
|
79
|
+
registryPath: relative(targetDirectory, registryPath),
|
|
80
|
+
createdFiles: Object.keys(files).map((fileName) =>
|
|
81
|
+
relative(targetDirectory, join(variantDirectory, fileName)),
|
|
82
|
+
),
|
|
83
|
+
factoryName,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/*** Create normal manifest, factory, and entrypoint source for one authored starter variant. */
|
|
88
|
+
function createTemplateFiles({ manifest, manifestName, factoryName }) {
|
|
89
|
+
const manifestSource = `import type { AppManifest } from '@ankhorage/contracts';
|
|
90
|
+
|
|
91
|
+
export const ${manifestName} = ${JSON.stringify(manifest, null, 2)} satisfies AppManifest;
|
|
92
|
+
`;
|
|
93
|
+
const templateSource = `import type { AppManifest } from '@ankhorage/contracts';
|
|
94
|
+
|
|
95
|
+
import type { TemplateSeed } from '../../../starter.types';
|
|
96
|
+
import { ${manifestName} } from './manifest';
|
|
97
|
+
|
|
98
|
+
/*** Create the authored starter while applying the caller's canonical app identity and theme. */
|
|
99
|
+
export function ${factoryName}(seed: TemplateSeed): AppManifest {
|
|
100
|
+
const theme = seed.theme ?? ${manifestName}.themes[0];
|
|
101
|
+
if (theme === undefined) {
|
|
102
|
+
throw new Error('The authored template requires one resolved theme.');
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
...${manifestName},
|
|
106
|
+
metadata: {
|
|
107
|
+
...${manifestName}.metadata,
|
|
108
|
+
name: seed.appName,
|
|
109
|
+
slug: seed.slug,
|
|
110
|
+
version: seed.version ?? ${manifestName}.metadata.version,
|
|
111
|
+
themeId: theme.id,
|
|
112
|
+
},
|
|
113
|
+
themes: [theme],
|
|
114
|
+
activeThemeId: theme.id,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
return {
|
|
119
|
+
'index.ts': `export { ${factoryName} } from './template';\n`,
|
|
120
|
+
'manifest.ts': manifestSource,
|
|
121
|
+
'template.ts': templateSource,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/*** Add one stable import and definition to an existing category registry. */
|
|
126
|
+
function updateCategoryRegistry(source, definition) {
|
|
127
|
+
const importLine = `import { ${definition.factoryName} } from './${definition.templateId}';`;
|
|
128
|
+
if (source.includes(`id: '${definition.templateId}'`) || source.includes(importLine)) {
|
|
129
|
+
throw new Error(`Template is already registered: ${definition.templateId}`);
|
|
130
|
+
}
|
|
131
|
+
const exportMarker = '\nexport const ';
|
|
132
|
+
const exportIndex = source.indexOf(exportMarker);
|
|
133
|
+
if (exportIndex < 0) {
|
|
134
|
+
throw new Error('Category registry does not expose its canonical template array.');
|
|
135
|
+
}
|
|
136
|
+
const prefixLines = source.slice(0, exportIndex).trimEnd().split('\n');
|
|
137
|
+
const relativeImports = [
|
|
138
|
+
...prefixLines.filter((line) => /^import .* from '\.\//u.test(line)),
|
|
139
|
+
importLine,
|
|
140
|
+
].sort((left, right) => left.localeCompare(right));
|
|
141
|
+
const preservedPrefix = prefixLines.filter((line) => !/^import .* from '\.\//u.test(line));
|
|
142
|
+
const withImport = `${[...preservedPrefix, ...relativeImports].join('\n')}\n${source.slice(exportIndex + 1)}`;
|
|
143
|
+
const closeMarker = '] satisfies readonly CategoryStarterTemplateDefinition[];';
|
|
144
|
+
const closeIndex = withImport.indexOf(closeMarker);
|
|
145
|
+
if (closeIndex < 0) {
|
|
146
|
+
throw new Error('Category registry is missing its canonical definition-array terminator.');
|
|
147
|
+
}
|
|
148
|
+
const entry = ` {
|
|
149
|
+
id: '${escapeSingleQuoted(definition.templateId)}',
|
|
150
|
+
label: '${escapeSingleQuoted(definition.label)}',
|
|
151
|
+
description: '${escapeSingleQuoted(definition.description)}',
|
|
152
|
+
create: ${definition.factoryName},
|
|
153
|
+
},
|
|
154
|
+
`;
|
|
155
|
+
return `${withImport.slice(0, closeIndex)}${entry}${withImport.slice(closeIndex)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/*** Convert kebab-case identifiers to a PascalCase source symbol. */
|
|
159
|
+
function toPascalCase(value) {
|
|
160
|
+
return value
|
|
161
|
+
.split('-')
|
|
162
|
+
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
|
|
163
|
+
.join('');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/*** Convert kebab-case identifiers to an uppercase constant name. */
|
|
167
|
+
function toConstantCase(value) {
|
|
168
|
+
return value.replaceAll('-', '_').toUpperCase();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/*** Escape content placed in generated single-quoted TypeScript strings. */
|
|
172
|
+
function escapeSingleQuoted(value) {
|
|
173
|
+
return value.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/*** Assert that a resolved output remains inside its declared owner directory. */
|
|
177
|
+
function assertInside(parentDirectory, childPath) {
|
|
178
|
+
const relativePath = relative(parentDirectory, childPath);
|
|
179
|
+
if (relativePath === '' || relativePath.startsWith(`..${sep}`) || relativePath === '..') {
|
|
180
|
+
throw new Error(`Scaffold path escapes its owner directory: ${childPath}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/*** Return whether a filesystem path already exists. */
|
|
185
|
+
async function pathExists(path) {
|
|
186
|
+
try {
|
|
187
|
+
await access(path);
|
|
188
|
+
return true;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/*** Require a non-array object input. */
|
|
196
|
+
function assertRecord(value, label) {
|
|
197
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
198
|
+
throw new Error(`${label} must be an object.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/*** Require a non-empty string input field. */
|
|
203
|
+
function assertNonEmptyString(value, label) {
|
|
204
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
205
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/*** Run deterministic Templates source scaffolding from one JSON input. */
|
|
210
|
+
async function main() {
|
|
211
|
+
const [inputPath] = process.argv.slice(2);
|
|
212
|
+
if (!inputPath) throw new Error('Usage: scaffold-template.mjs <scaffold-input.json>');
|
|
213
|
+
const input = JSON.parse(await readFile(inputPath, 'utf8'));
|
|
214
|
+
console.log(JSON.stringify(await scaffoldTemplate(input), null, 2));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
218
|
+
main().catch((error) => {
|
|
219
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
});
|
|
222
|
+
}
|