@volter/project 0.5.211 → 0.5.213
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/NOTICE +2 -2
- package/README.md +8 -15
- package/package.json +5 -26
- package/schemas/volter-project.schema.json +33 -145
- package/schemas/volter-settings.schema.json +11 -49
- package/src/adapter/adapter-module.ts +208 -465
- package/src/adapter/adapter-surface.ts +4 -32
- package/src/adapter/authoring-seam-contract.ts +7 -24
- package/src/adapter/authoring.ts +126 -265
- package/src/adapter/editor-looks.ts +98 -127
- package/src/adapter/finders/registry.ts +6 -21
- package/src/adapter/host-context.ts +17 -72
- package/src/adapter/index.ts +4 -77
- package/src/adapter/manifest-interpreter.ts +7 -50
- package/src/adapter/physics-adapter-2d.ts +4 -21
- package/src/adapter/root-adapter.ts +37 -111
- package/src/adapter/root-seam-contract.ts +3 -6
- package/src/adapter/root-stacking.ts +4 -46
- package/src/adapter/seam-evidence.ts +0 -16
- package/src/adapter/system-adapter.ts +24 -656
- package/src/adapter/system-seam-contract.ts +1 -91
- package/src/adapter/transform.ts +4 -3
- package/src/adapter/workspace-arrangement.ts +33 -49
- package/src/manifest/editor-port.ts +10 -36
- package/src/manifest/filename.ts +3 -42
- package/src/manifest/load-file.ts +9 -15
- package/src/manifest/load.ts +44 -179
- package/src/manifest/locate.ts +8 -32
- package/src/manifest/schema.ts +155 -427
- package/src/semver.ts +16 -0
- package/src/settings/keys.ts +9 -11
- package/src/settings/schema.ts +55 -44
- package/schemas/engine-api.json +0 -57
- package/schemas/engine-api.md +0 -22
- package/src/adapter/binding.ts +0 -445
- package/src/adapter/debug-registry-ref.ts +0 -19
- package/src/adapter/frame-capture.ts +0 -80
- package/src/adapter/ingest/contract-system-slots.ts +0 -28
- package/src/adapter/ingest/game-contract-seams.ts +0 -86
- package/src/adapter/ingest/game-contract.ts +0 -506
- package/src/adapter/ingest/mount-readiness.ts +0 -140
- package/src/adapter/loop-gate-report.ts +0 -53
- package/src/adapter/native-entry-surface.ts +0 -100
- package/src/adapter/render-audio.ts +0 -20
- package/src/adapter/render-memory.ts +0 -34
- package/src/adapter/renderer-config.ts +0 -84
- package/src/adapter/system-slot.ts +0 -301
- package/src/core/system-phase.ts +0 -52
- package/src/inspection.ts +0 -281
- package/src/manifest/configuration-kinds.ts +0 -241
- package/src/manifest/kind-modules.ts +0 -58
- package/src/manifest/runtime-environment.ts +0 -48
package/src/inspection.ts
DELETED
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Read-only first-contact inspection for a folder the user may want to open
|
|
3
|
-
* or adapt. Detection is deliberately evidence-based and never writes files.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export type ProjectWorkspaceState =
|
|
7
|
-
| 'volter-project'
|
|
8
|
-
| 'adaptable-project'
|
|
9
|
-
| 'empty-folder'
|
|
10
|
-
| 'unknown-project';
|
|
11
|
-
|
|
12
|
-
export type SuggestedAdapterSurface = 'three' | 'canvas' | 'dom';
|
|
13
|
-
|
|
14
|
-
export interface ProjectInspection {
|
|
15
|
-
workspaceState: ProjectWorkspaceState;
|
|
16
|
-
detectedTechnologies: string[];
|
|
17
|
-
suggestedSurface: SuggestedAdapterSurface | null;
|
|
18
|
-
entryCandidates: string[];
|
|
19
|
-
/** Evidence that makes automatic one-file metadata adaptation unsafe. */
|
|
20
|
-
adaptationBlockers: string[];
|
|
21
|
-
hasManifest: boolean;
|
|
22
|
-
writesPerformed: false;
|
|
23
|
-
summary: string;
|
|
24
|
-
nextAction: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface ProjectInspectionReader {
|
|
28
|
-
exists(path: string): Promise<boolean>;
|
|
29
|
-
read(path: string): Promise<string>;
|
|
30
|
-
list(path: string): Promise<ProjectInspectionEntry[]>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface ProjectInspectionEntry {
|
|
34
|
-
name: string;
|
|
35
|
-
path: string;
|
|
36
|
-
type: 'file' | 'dir';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface IngestManifestOptions {
|
|
40
|
-
name: string;
|
|
41
|
-
engineVersion: string;
|
|
42
|
-
surface?: SuggestedAdapterSurface | undefined;
|
|
43
|
-
entry?: string | undefined;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const IGNORED_DIRECTORIES = new Set([
|
|
47
|
-
'.git',
|
|
48
|
-
'.volter',
|
|
49
|
-
'build',
|
|
50
|
-
'coverage',
|
|
51
|
-
'dist',
|
|
52
|
-
'node_modules',
|
|
53
|
-
'out',
|
|
54
|
-
]);
|
|
55
|
-
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.ts', '.tsx']);
|
|
56
|
-
const MAX_VISITED_FILES = 500;
|
|
57
|
-
const MAX_DIRECTORY_DEPTH = 4;
|
|
58
|
-
|
|
59
|
-
function extension(path: string): string {
|
|
60
|
-
const dot = path.lastIndexOf('.');
|
|
61
|
-
return dot < 0 ? '' : path.slice(dot).toLowerCase();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function dependencyNames(value: unknown): Set<string> {
|
|
65
|
-
if (!value || typeof value !== 'object') return new Set();
|
|
66
|
-
const record = value as Record<string, unknown>;
|
|
67
|
-
const names = new Set<string>();
|
|
68
|
-
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
|
69
|
-
const dependencies = record[field];
|
|
70
|
-
if (!dependencies || typeof dependencies !== 'object') continue;
|
|
71
|
-
for (const name of Object.keys(dependencies as Record<string, unknown>)) names.add(name);
|
|
72
|
-
}
|
|
73
|
-
return names;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function readPackageDependencies(reader: ProjectInspectionReader): Promise<Set<string>> {
|
|
77
|
-
if (!(await reader.exists('package.json'))) return new Set();
|
|
78
|
-
try {
|
|
79
|
-
return dependencyNames(JSON.parse(await reader.read('package.json')));
|
|
80
|
-
} catch {
|
|
81
|
-
return new Set();
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: bounded breadth-first file evidence collection keeps traversal limits and filtering in one place.
|
|
86
|
-
async function collectSourceFiles(reader: ProjectInspectionReader): Promise<string[]> {
|
|
87
|
-
const files: string[] = [];
|
|
88
|
-
const queue: Array<{ path: string; depth: number }> = [{ path: '', depth: 0 }];
|
|
89
|
-
while (queue.length > 0 && files.length < MAX_VISITED_FILES) {
|
|
90
|
-
const current = queue.shift();
|
|
91
|
-
if (!current) break;
|
|
92
|
-
let entries: ProjectInspectionEntry[];
|
|
93
|
-
try {
|
|
94
|
-
entries = await reader.list(current.path);
|
|
95
|
-
} catch {
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
for (const entry of entries) {
|
|
99
|
-
if (entry.type === 'dir') {
|
|
100
|
-
if (current.depth < MAX_DIRECTORY_DEPTH && !IGNORED_DIRECTORIES.has(entry.name)) {
|
|
101
|
-
queue.push({ path: entry.path, depth: current.depth + 1 });
|
|
102
|
-
}
|
|
103
|
-
} else if (SOURCE_EXTENSIONS.has(extension(entry.path)) || entry.path === 'index.html') {
|
|
104
|
-
files.push(entry.path);
|
|
105
|
-
if (files.length >= MAX_VISITED_FILES) break;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return files;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function candidateEntries(files: string[]): string[] {
|
|
113
|
-
const preferredNames = [
|
|
114
|
-
'src/volter-entry.tsx',
|
|
115
|
-
'src/volter-entry.ts',
|
|
116
|
-
'src/volter-host.tsx',
|
|
117
|
-
'src/volter-host.ts',
|
|
118
|
-
'src/adapter.tsx',
|
|
119
|
-
'src/adapter.ts',
|
|
120
|
-
'src/main.tsx',
|
|
121
|
-
'src/main.ts',
|
|
122
|
-
'src/index.tsx',
|
|
123
|
-
'src/index.ts',
|
|
124
|
-
'src/main.jsx',
|
|
125
|
-
'src/main.js',
|
|
126
|
-
'src/index.jsx',
|
|
127
|
-
'src/index.js',
|
|
128
|
-
'index.html',
|
|
129
|
-
];
|
|
130
|
-
const available = new Set(files);
|
|
131
|
-
const preferred = preferredNames.filter((name) => available.has(name));
|
|
132
|
-
return [...preferred, ...files.filter((name) => !preferred.includes(name))].slice(0, 8);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Inspect a folder without mutating it or assuming that it is already VOLTER-shaped. */
|
|
136
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one ordered evidence-to-recommendation decision table.
|
|
137
|
-
export async function inspectProject(reader: ProjectInspectionReader): Promise<ProjectInspection> {
|
|
138
|
-
if (await reader.exists('volter.project.json')) {
|
|
139
|
-
return {
|
|
140
|
-
workspaceState: 'volter-project',
|
|
141
|
-
detectedTechnologies: ['Volter Editor'],
|
|
142
|
-
suggestedSurface: null,
|
|
143
|
-
entryCandidates: [],
|
|
144
|
-
adaptationBlockers: [],
|
|
145
|
-
hasManifest: true,
|
|
146
|
-
writesPerformed: false,
|
|
147
|
-
summary: 'This folder is already a Volter Editor project.',
|
|
148
|
-
nextAction: 'Open it in the editor.',
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// An inaccessible root is not an empty folder. Propagate the read failure
|
|
153
|
-
// so callers can report it honestly instead of offering to scaffold over
|
|
154
|
-
// a location they could not inspect.
|
|
155
|
-
const rootEntries = await reader.list('');
|
|
156
|
-
if (rootEntries.length === 0) {
|
|
157
|
-
return {
|
|
158
|
-
workspaceState: 'empty-folder',
|
|
159
|
-
detectedTechnologies: [],
|
|
160
|
-
suggestedSurface: null,
|
|
161
|
-
entryCandidates: [],
|
|
162
|
-
adaptationBlockers: [],
|
|
163
|
-
hasManifest: false,
|
|
164
|
-
writesPerformed: false,
|
|
165
|
-
summary: 'This folder is empty.',
|
|
166
|
-
nextAction: 'Create a new Volter Editor project here.',
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const dependencies = await readPackageDependencies(reader);
|
|
171
|
-
const files = await collectSourceFiles(reader);
|
|
172
|
-
const technologies: string[] = [];
|
|
173
|
-
if (dependencies.has('@react-three/fiber')) technologies.push('React Three Fiber');
|
|
174
|
-
if (dependencies.has('three')) technologies.push('three.js');
|
|
175
|
-
if (dependencies.has('pixi.js') || [...dependencies].some((name) => name.startsWith('@pixi/'))) {
|
|
176
|
-
technologies.push('PixiJS');
|
|
177
|
-
}
|
|
178
|
-
if (dependencies.has('react')) technologies.push('React');
|
|
179
|
-
if (dependencies.has('vite')) technologies.push('Vite');
|
|
180
|
-
|
|
181
|
-
const suggestedSurface: SuggestedAdapterSurface | null = technologies.some(
|
|
182
|
-
(name) => name === 'three.js' || name === 'React Three Fiber',
|
|
183
|
-
)
|
|
184
|
-
? 'three'
|
|
185
|
-
: technologies.includes('PixiJS')
|
|
186
|
-
? 'canvas'
|
|
187
|
-
: technologies.includes('React')
|
|
188
|
-
? 'dom'
|
|
189
|
-
: null;
|
|
190
|
-
const entries = candidateEntries(files);
|
|
191
|
-
const adaptationBlockers: string[] = [];
|
|
192
|
-
if (suggestedSurface && entries.length === 0) {
|
|
193
|
-
adaptationBlockers.push('No JavaScript or TypeScript entry candidate was found.');
|
|
194
|
-
}
|
|
195
|
-
if (suggestedSurface === 'dom' && entries[0]) {
|
|
196
|
-
let entrySource = '';
|
|
197
|
-
try {
|
|
198
|
-
entrySource = await reader.read(entries[0]);
|
|
199
|
-
} catch {
|
|
200
|
-
adaptationBlockers.push(`The suggested React entry ${entries[0]} could not be read.`);
|
|
201
|
-
}
|
|
202
|
-
if (/\b(?:ReactDOM\s*\.\s*render|createRoot|hydrateRoot)\s*\(/.test(entrySource)) {
|
|
203
|
-
adaptationBlockers.push(
|
|
204
|
-
`The React app mounts itself in ${entries[0]}; Volter Editor ingest requires a host-shim module that default-exports the mounted component and preserves the app's providers.`,
|
|
205
|
-
);
|
|
206
|
-
} else if (!/\bexport\s+default\b/.test(entrySource)) {
|
|
207
|
-
adaptationBlockers.push(
|
|
208
|
-
`The React entry ${entries[0]} does not default-export a component; create a host-shim module that does before adding Volter Editor metadata.`,
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
const adaptable = suggestedSurface !== null && adaptationBlockers.length === 0;
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
workspaceState: adaptable ? 'adaptable-project' : 'unknown-project',
|
|
216
|
-
detectedTechnologies: technologies,
|
|
217
|
-
suggestedSurface,
|
|
218
|
-
entryCandidates: entries,
|
|
219
|
-
adaptationBlockers,
|
|
220
|
-
hasManifest: false,
|
|
221
|
-
writesPerformed: false,
|
|
222
|
-
summary: adaptable
|
|
223
|
-
? `This looks like an existing ${technologies.join(' + ') || 'web'} project.`
|
|
224
|
-
: suggestedSurface
|
|
225
|
-
? `This ${technologies.join(' + ') || 'web'} project needs an adapter step before Volter Editor metadata can be added safely.`
|
|
226
|
-
: 'This folder is not empty, but its game framework could not be identified.',
|
|
227
|
-
nextAction: adaptable
|
|
228
|
-
? 'Review the detected entry and adapter, then add Volter Editor metadata without rewriting the game source.'
|
|
229
|
-
: (adaptationBlockers[0] ??
|
|
230
|
-
'Choose the entry point and adapter explicitly before adding Volter Editor metadata.'),
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Build the one-file, source-preserving metadata bridge for a detected app.
|
|
236
|
-
* The caller is responsible for obtaining explicit consent before writing it.
|
|
237
|
-
*/
|
|
238
|
-
export function createIngestManifest(
|
|
239
|
-
inspection: ProjectInspection,
|
|
240
|
-
options: IngestManifestOptions,
|
|
241
|
-
): Record<string, unknown> {
|
|
242
|
-
const surface = options.surface ?? inspection.suggestedSurface;
|
|
243
|
-
const entry = options.entry ?? inspection.entryCandidates[0];
|
|
244
|
-
const portableEntry = entry?.replace(/\\/g, '/');
|
|
245
|
-
const entryEscapesProject =
|
|
246
|
-
portableEntry !== undefined &&
|
|
247
|
-
(portableEntry.startsWith('/') ||
|
|
248
|
-
portableEntry.startsWith('//') ||
|
|
249
|
-
/^[A-Za-z]:\//.test(portableEntry) ||
|
|
250
|
-
portableEntry.split('/').includes('..'));
|
|
251
|
-
const explicitUnknownOverride =
|
|
252
|
-
inspection.workspaceState === 'unknown-project' &&
|
|
253
|
-
options.surface !== undefined &&
|
|
254
|
-
options.entry !== undefined;
|
|
255
|
-
if (
|
|
256
|
-
(inspection.workspaceState !== 'adaptable-project' && !explicitUnknownOverride) ||
|
|
257
|
-
!surface ||
|
|
258
|
-
!entry ||
|
|
259
|
-
entryEscapesProject
|
|
260
|
-
) {
|
|
261
|
-
throw new Error(
|
|
262
|
-
entryEscapesProject
|
|
263
|
-
? 'Adapter entry must be a project-relative path that stays inside the selected folder.'
|
|
264
|
-
: (inspection.adaptationBlockers[0] ??
|
|
265
|
-
'Choose a supported adapter surface and source entry before adapting this folder.'),
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
return {
|
|
269
|
-
manifestVersion: 2,
|
|
270
|
-
name: options.name,
|
|
271
|
-
version: '0.1.0',
|
|
272
|
-
engine: { version: options.engineVersion },
|
|
273
|
-
roots: [
|
|
274
|
-
{
|
|
275
|
-
id: 'game',
|
|
276
|
-
adapter: { surface, ingest: {} },
|
|
277
|
-
entry,
|
|
278
|
-
},
|
|
279
|
-
],
|
|
280
|
-
};
|
|
281
|
-
}
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE RUN-CONFIGURATION KIND REGISTRY — the open set of kinds a manifest's
|
|
3
|
-
* `configurations[]` may declare (ARCHITECTURE-CORE §The project model). A run
|
|
4
|
-
* configuration is one of the project's ENTRYPOINTS: something the editor,
|
|
5
|
-
* the CLI or a harness can start by id. The engine registers two kinds
|
|
6
|
-
* here; a capability registers more through the same door; the manifest
|
|
7
|
-
* validates each declaration against its kind's own schema at load, and an
|
|
8
|
-
* unregistered kind refuses loudly naming the registered ones.
|
|
9
|
-
*
|
|
10
|
-
* The implicit configuration every project with roots has is `play` — mount
|
|
11
|
-
* every root in the host — which is never declared and may be named by a
|
|
12
|
-
* `compound`.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { type ZodType, z } from 'zod';
|
|
16
|
-
|
|
17
|
-
/** The envelope every declaration carries; the rest is the kind's. */
|
|
18
|
-
export interface ConfigurationEnvelope {
|
|
19
|
-
readonly id: string;
|
|
20
|
-
readonly kind: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type ConfigurationRole = 'run' | 'build';
|
|
24
|
-
|
|
25
|
-
export interface ConfigurationKindRegistration<
|
|
26
|
-
T extends ConfigurationEnvelope = ConfigurationEnvelope,
|
|
27
|
-
> {
|
|
28
|
-
readonly kind: T['kind'];
|
|
29
|
-
/** Where a configuration of this kind surfaces and which verb starts it:
|
|
30
|
-
* `run` (the transport) or `build` (Build Profiles, Export). */
|
|
31
|
-
readonly role: ConfigurationRole;
|
|
32
|
-
/** The declaration's full shape, envelope included; `.strict()`. */
|
|
33
|
-
readonly schema: ZodType<T>;
|
|
34
|
-
/** One line for schema output and refusals. */
|
|
35
|
-
readonly describe: string;
|
|
36
|
-
/** A build-role kind backed by a project TOOL (the operation catalog):
|
|
37
|
-
* the host runs `tool` with `input(configuration)` and reports its
|
|
38
|
-
* outcome as the build's. This is how a capability's bake becomes a
|
|
39
|
-
* build without the engine knowing what a bake is. */
|
|
40
|
-
readonly build?: { readonly tool: string; readonly input: (configuration: T) => unknown };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* What a CONTRIBUTION exports to register a kind — a plain object, so a
|
|
45
|
-
* project module (evaluated with the project's own copy of the engine)
|
|
46
|
-
* hands it to the HOST, which registers it in the host's registry. A module
|
|
47
|
-
* `editor/contributions/<name>.kind.ts` exports it as `kind`.
|
|
48
|
-
*/
|
|
49
|
-
export type ConfigurationKindContribution = ConfigurationKindRegistration;
|
|
50
|
-
|
|
51
|
-
const CONFIGURATION_ROLES: readonly ConfigurationRole[] = ['run', 'build'];
|
|
52
|
-
|
|
53
|
-
/** Register a contribution's kind after checking its shape by name. */
|
|
54
|
-
export function registerContributedConfigurationKind(value: unknown, source: string): () => void {
|
|
55
|
-
const record = (value ?? {}) as Partial<ConfigurationKindContribution>;
|
|
56
|
-
const problems: string[] = [];
|
|
57
|
-
if (typeof record.kind !== 'string' || record.kind.length === 0)
|
|
58
|
-
problems.push('`kind` must be a non-empty string');
|
|
59
|
-
if (!CONFIGURATION_ROLES.includes(record.role as ConfigurationRole))
|
|
60
|
-
problems.push("`role` must be 'run' or 'build'");
|
|
61
|
-
if (typeof record.describe !== 'string') problems.push('`describe` must be a string');
|
|
62
|
-
const schema = record.schema as { safeParse?: unknown } | undefined;
|
|
63
|
-
if (!schema || typeof schema.safeParse !== 'function')
|
|
64
|
-
problems.push('`schema` must be a Zod schema');
|
|
65
|
-
const build = record.build as { tool?: unknown; input?: unknown } | undefined;
|
|
66
|
-
if (
|
|
67
|
-
build !== undefined &&
|
|
68
|
-
(typeof build.tool !== 'string' || typeof build.input !== 'function')
|
|
69
|
-
) {
|
|
70
|
-
problems.push('`build` must be `{ tool: string; input(configuration) }`');
|
|
71
|
-
}
|
|
72
|
-
if (problems.length > 0) {
|
|
73
|
-
throw new Error(`${source}: not a configuration kind — ${problems.join('; ')}`);
|
|
74
|
-
}
|
|
75
|
-
return registerConfigurationKind(record as ConfigurationKindContribution);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const registry = new Map<string, ConfigurationKindRegistration>();
|
|
79
|
-
|
|
80
|
-
/** Register a kind. A second registration replaces the first (a module
|
|
81
|
-
* re-evaluated on save); never throws. */
|
|
82
|
-
export function registerConfigurationKind<T extends ConfigurationEnvelope>(
|
|
83
|
-
registration: ConfigurationKindRegistration<T>,
|
|
84
|
-
): () => void {
|
|
85
|
-
const entry = registration as unknown as ConfigurationKindRegistration;
|
|
86
|
-
registry.set(registration.kind, entry);
|
|
87
|
-
return () => {
|
|
88
|
-
if (registry.get(registration.kind) === entry) registry.delete(registration.kind);
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function registeredConfigurationKinds(): readonly string[] {
|
|
93
|
-
return [...registry.keys()];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function configurationKind(kind: string): ConfigurationKindRegistration | undefined {
|
|
97
|
-
return registry.get(kind);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** The id of the implicit configuration: mount every root in the host. */
|
|
101
|
-
export const PLAY_CONFIGURATION_ID = 'play';
|
|
102
|
-
|
|
103
|
-
/** A process the project runs beside the host: an entry module started
|
|
104
|
-
* with the project's own toolchain, listening on a port when it has one. */
|
|
105
|
-
export interface ProcessConfiguration extends ConfigurationEnvelope {
|
|
106
|
-
readonly kind: 'process';
|
|
107
|
-
/** Project-relative entry module (`server/main.ts`). */
|
|
108
|
-
readonly entry: string;
|
|
109
|
-
/** The command that starts it, run in a shell at `cwd`; `npx tsx <entry>`
|
|
110
|
-
* when omitted. Declare it when the entry needs its own flags. */
|
|
111
|
-
readonly command?: string;
|
|
112
|
-
/** The port it listens on, when readiness means a port. */
|
|
113
|
-
readonly port?: number;
|
|
114
|
-
/** Project-relative working directory; the project root when omitted. */
|
|
115
|
-
readonly cwd?: string;
|
|
116
|
-
readonly env?: Readonly<Record<string, string>>;
|
|
117
|
-
/** How a runner knows it is up: the port accepting connections (the
|
|
118
|
-
* default when `port` is set), or an HTTP path answering 2xx on it. */
|
|
119
|
-
readonly ready?: { readonly http?: string };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Several configurations started together, `play` included by name. */
|
|
123
|
-
export interface CompoundConfiguration extends ConfigurationEnvelope {
|
|
124
|
-
readonly kind: 'compound';
|
|
125
|
-
readonly run: readonly string[];
|
|
126
|
-
/** How many instances of the host mount to run against the rest (the
|
|
127
|
-
* editor's Instances picker is this parameter). */
|
|
128
|
-
readonly instances?: number;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** A packaged build of the project's own web bundle: the project's build
|
|
132
|
-
* script, then `dist/` packaged as the web artifact. The first build-role
|
|
133
|
-
* kind; a capability registers more (a bake, a static site). */
|
|
134
|
-
export interface BundleConfiguration extends ConfigurationEnvelope {
|
|
135
|
-
readonly kind: 'bundle';
|
|
136
|
-
/** The package.json script that builds (`build`). */
|
|
137
|
-
readonly script?: string;
|
|
138
|
-
/** Extra arguments after `--` (`['--base', './']` for an itch-style relative bundle). */
|
|
139
|
-
readonly args?: readonly string[];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export type DeclaredConfiguration = ConfigurationEnvelope & Readonly<Record<string, unknown>>;
|
|
143
|
-
|
|
144
|
-
registerConfigurationKind<ProcessConfiguration>({
|
|
145
|
-
kind: 'process',
|
|
146
|
-
role: 'run',
|
|
147
|
-
describe: 'A process beside the host: an entry module, a port, readiness',
|
|
148
|
-
schema: z
|
|
149
|
-
.object({
|
|
150
|
-
id: z.string().min(1).describe('Configuration id'),
|
|
151
|
-
kind: z.literal('process'),
|
|
152
|
-
entry: z.string().min(1).describe('Project-relative entry module'),
|
|
153
|
-
command: z
|
|
154
|
-
.string()
|
|
155
|
-
.min(1)
|
|
156
|
-
.optional()
|
|
157
|
-
.describe('Shell command that starts it; `npx tsx <entry>` when omitted'),
|
|
158
|
-
port: z.number().int().min(1).max(65535).optional().describe('Port it listens on'),
|
|
159
|
-
cwd: z.string().min(1).optional().describe('Project-relative working directory'),
|
|
160
|
-
env: z.record(z.string(), z.string()).optional().describe('Environment for the process'),
|
|
161
|
-
ready: z
|
|
162
|
-
.object({ http: z.string().min(1).optional().describe('HTTP path answering 2xx when up') })
|
|
163
|
-
.strict()
|
|
164
|
-
.optional()
|
|
165
|
-
.describe('Readiness beyond the port accepting connections'),
|
|
166
|
-
})
|
|
167
|
-
.strict() as unknown as ZodType<ProcessConfiguration>,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
registerConfigurationKind<CompoundConfiguration>({
|
|
171
|
-
kind: 'compound',
|
|
172
|
-
role: 'run',
|
|
173
|
-
describe: 'Several configurations started together; `play` names the host mount',
|
|
174
|
-
schema: z
|
|
175
|
-
.object({
|
|
176
|
-
id: z.string().min(1).describe('Configuration id'),
|
|
177
|
-
kind: z.literal('compound'),
|
|
178
|
-
run: z.array(z.string().min(1)).min(1).describe('Configuration ids, `play` included'),
|
|
179
|
-
instances: z
|
|
180
|
-
.number()
|
|
181
|
-
.int()
|
|
182
|
-
.min(1)
|
|
183
|
-
.optional()
|
|
184
|
-
.describe('Instances of the host mount to run against the rest'),
|
|
185
|
-
})
|
|
186
|
-
.strict() as unknown as ZodType<CompoundConfiguration>,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
registerConfigurationKind<BundleConfiguration>({
|
|
190
|
-
kind: 'bundle',
|
|
191
|
-
role: 'build',
|
|
192
|
-
describe: "The project's web bundle: its build script, then dist/ packaged as the artifact",
|
|
193
|
-
schema: z
|
|
194
|
-
.object({
|
|
195
|
-
id: z.string().min(1).describe('Configuration id'),
|
|
196
|
-
kind: z.literal('bundle'),
|
|
197
|
-
script: z.string().min(1).optional().describe('package.json script that builds (`build`)'),
|
|
198
|
-
args: z.array(z.string()).optional().describe('Arguments passed after `--`'),
|
|
199
|
-
})
|
|
200
|
-
.strict() as unknown as ZodType<BundleConfiguration>,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
/** Validate `configurations[]` against the registry: every kind registered, every
|
|
204
|
-
* declaration its kind's shape, ids unique, compound members declared. */
|
|
205
|
-
export function configurationIssues(configurations: readonly ConfigurationEnvelope[]): string[] {
|
|
206
|
-
const issues: string[] = [];
|
|
207
|
-
const ids = new Set<string>();
|
|
208
|
-
for (const declaration of configurations) {
|
|
209
|
-
if (ids.has(declaration.id)) issues.push(`configurations: duplicate id "${declaration.id}"`);
|
|
210
|
-
ids.add(declaration.id);
|
|
211
|
-
const registration = registry.get(declaration.kind);
|
|
212
|
-
if (!registration) {
|
|
213
|
-
issues.push(
|
|
214
|
-
`configurations "${declaration.id}": unknown kind "${declaration.kind}" — registered: ${registeredConfigurationKinds()
|
|
215
|
-
.map((k) => `"${k}"`)
|
|
216
|
-
.join(', ')}`,
|
|
217
|
-
);
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
const parsed = registration.schema.safeParse(declaration);
|
|
221
|
-
if (!parsed.success) {
|
|
222
|
-
for (const issue of parsed.error.issues) {
|
|
223
|
-
issues.push(
|
|
224
|
-
`configurations "${declaration.id}" (${declaration.kind}): ${issue.path.join('.') || '(declaration)'}: ${issue.message}`,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
for (const declaration of configurations) {
|
|
230
|
-
if (declaration.kind !== 'compound') continue;
|
|
231
|
-
const members = (declaration as { run?: readonly string[] }).run ?? [];
|
|
232
|
-
for (const member of members) {
|
|
233
|
-
if (member !== PLAY_CONFIGURATION_ID && !ids.has(member)) {
|
|
234
|
-
issues.push(
|
|
235
|
-
`configurations "${declaration.id}": member "${member}" is not a declared configuration (or "${PLAY_CONFIGURATION_ID}")`,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return issues;
|
|
241
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where a project's CONFIGURATION-KIND CONTRIBUTIONS live and how a host
|
|
3
|
-
* takes them in (ARCHITECTURE-CORE §The project model). A module named
|
|
4
|
-
* `editor/contributions/<name>.kind.ts` (or `.tsx`) exports `kind`, a plain
|
|
5
|
-
* `ConfigurationKindContribution`; the HOST that loads the manifest — the
|
|
6
|
-
* editor server through Vite's SSR loader, a project's own validate script
|
|
7
|
-
* through tsx — imports each and registers it in its own registry through
|
|
8
|
-
* {@link registerContributedConfigurationKind}. Node-only: it walks the
|
|
9
|
-
* project folder.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { readdirSync, statSync } from 'node:fs';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
-
import { registerContributedConfigurationKind } from './configuration-kinds';
|
|
15
|
-
|
|
16
|
-
const KIND_SUFFIXES = ['.kind.ts', '.kind.tsx'] as const;
|
|
17
|
-
|
|
18
|
-
/** Whether one file name is a kind contribution by the convention. */
|
|
19
|
-
export function isConfigurationKindModule(fileName: string): boolean {
|
|
20
|
-
return KIND_SUFFIXES.some((suffix) => fileName.endsWith(suffix));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Absolute paths of the project's kind contributions, sorted. */
|
|
24
|
-
export function contributedKindModulePaths(projectDir: string): string[] {
|
|
25
|
-
const found: string[] = [];
|
|
26
|
-
const walk = (directory: string): void => {
|
|
27
|
-
let names: string[];
|
|
28
|
-
try {
|
|
29
|
-
names = readdirSync(directory);
|
|
30
|
-
} catch {
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
for (const name of names) {
|
|
34
|
-
if (name.startsWith('.') || name === 'node_modules') continue;
|
|
35
|
-
const absolute = join(directory, name);
|
|
36
|
-
let isDirectory = false;
|
|
37
|
-
try {
|
|
38
|
-
isDirectory = statSync(absolute).isDirectory();
|
|
39
|
-
} catch {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
if (isDirectory) walk(absolute);
|
|
43
|
-
else if (isConfigurationKindModule(name)) found.push(absolute);
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
walk(join(projectDir, 'editor', 'contributions'));
|
|
47
|
-
return found.sort();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Register the `kind` export of an already-imported module; the source
|
|
51
|
-
* path names the module in a refusal. */
|
|
52
|
-
export function registerKindModule(module: unknown, source: string): () => void {
|
|
53
|
-
const exported = (module as { kind?: unknown } | null)?.kind;
|
|
54
|
-
if (exported === undefined) {
|
|
55
|
-
throw new Error(`${source}: a kind contribution must export \`kind\``);
|
|
56
|
-
}
|
|
57
|
-
return registerContributedConfigurationKind(exported, source);
|
|
58
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE RUNTIME HALF OF A PROJECT'S ENVIRONMENT (ARCHITECTURE-CORE §The
|
|
3
|
-
* project model, "environment"): the Node version a project's processes need
|
|
4
|
-
* is declared ONCE, where the ecosystem already declares it —
|
|
5
|
-
* `package.json` `engines.node` — and read here by whatever starts a
|
|
6
|
-
* `process` configuration (the editor server's configurations route, the
|
|
7
|
-
* CLI's autoboot). The manifest carries no `environment` key: a second
|
|
8
|
-
* declaration of the same fact is the drift a single home prevents.
|
|
9
|
-
*
|
|
10
|
-
* Node-only (reads the project folder), like `locate.ts`.
|
|
11
|
-
*/
|
|
12
|
-
import { readFileSync } from 'node:fs';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
-
import semver from 'semver';
|
|
15
|
-
|
|
16
|
-
/** `engines.node` from the project's `package.json`, or `null` when the
|
|
17
|
-
* project declares none (or has no package.json). */
|
|
18
|
-
export function declaredNodeRange(projectDir: string): string | null {
|
|
19
|
-
try {
|
|
20
|
-
const raw: unknown = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
|
|
21
|
-
const engines = (raw as { engines?: { node?: unknown } } | null)?.engines;
|
|
22
|
-
return typeof engines?.node === 'string' && engines.node.trim() ? engines.node.trim() : null;
|
|
23
|
-
} catch {
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Why a process of this project cannot start on the running Node, spelled
|
|
30
|
-
* for the person who will fix it — or `null` when it can. A range that is
|
|
31
|
-
* not valid semver is itself the issue: a declaration nobody can satisfy is
|
|
32
|
-
* named, never skipped.
|
|
33
|
-
*/
|
|
34
|
-
export function nodeRuntimeIssue(
|
|
35
|
-
projectDir: string,
|
|
36
|
-
running: string = process.versions.node,
|
|
37
|
-
): string | null {
|
|
38
|
-
const range = declaredNodeRange(projectDir);
|
|
39
|
-
if (range === null) return null;
|
|
40
|
-
if (semver.validRange(range) === null) {
|
|
41
|
-
return `package.json declares engines.node "${range}", which is not a semver range`;
|
|
42
|
-
}
|
|
43
|
-
if (semver.satisfies(running, range, { includePrerelease: true })) return null;
|
|
44
|
-
return (
|
|
45
|
-
`package.json declares engines.node "${range}" and this host runs Node ${running} — ` +
|
|
46
|
-
`run the editor and \`volter\` on a Node that satisfies it, or change engines.node`
|
|
47
|
-
);
|
|
48
|
-
}
|