@forgeailab/spark 0.1.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/README.md +97 -0
- package/package.json +34 -0
- package/src/cli.ts +47 -0
- package/src/commands/add.ts +367 -0
- package/src/commands/check.ts +166 -0
- package/src/commands/info.ts +98 -0
- package/src/commands/list.ts +79 -0
- package/src/commands/preset.ts +49 -0
- package/src/config.ts +33 -0
- package/src/io/board.ts +203 -0
- package/src/io/deps.ts +54 -0
- package/src/io/env.ts +58 -0
- package/src/io/files.ts +259 -0
- package/src/io/paths.ts +57 -0
- package/src/io/registry.ts +193 -0
- package/src/io/skills.ts +128 -0
- package/src/io/state.ts +59 -0
- package/src/resolver.ts +381 -0
- package/src/runtime-package.ts +132 -0
package/src/resolver.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXCLUSIVE_CAPABILITIES,
|
|
3
|
+
type PackCapability,
|
|
4
|
+
type PackManifest,
|
|
5
|
+
type TemplateCapability,
|
|
6
|
+
type TemplateManifest,
|
|
7
|
+
} from '@forgeailab/spark-schema';
|
|
8
|
+
|
|
9
|
+
export type PackRegistryEntry = {
|
|
10
|
+
name: string;
|
|
11
|
+
manifest: PackManifest;
|
|
12
|
+
dir?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ResolverRegistry = {
|
|
16
|
+
packs: ReadonlyMap<string, PackRegistryEntry>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ResolverTemplate = Pick<TemplateManifest, 'name' | 'provides'>;
|
|
20
|
+
|
|
21
|
+
export type InstallPlanPack = {
|
|
22
|
+
name: string;
|
|
23
|
+
manifest: PackManifest;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type InstallPlan = {
|
|
27
|
+
packs: InstallPlanPack[];
|
|
28
|
+
alreadyInstalled: string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type MissingCapabilityError = {
|
|
32
|
+
type: 'missing-capability';
|
|
33
|
+
pack: string;
|
|
34
|
+
capability: PackCapability;
|
|
35
|
+
providers: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ExclusiveConflictError = {
|
|
39
|
+
type: 'exclusive-conflict';
|
|
40
|
+
capability: PackCapability;
|
|
41
|
+
packs: [string, string];
|
|
42
|
+
source: 'exclusive-capability' | 'declared-conflict';
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type ScaffoldIncompatError = {
|
|
46
|
+
type: 'scaffold-incompat';
|
|
47
|
+
pack: string;
|
|
48
|
+
activeScaffold: string;
|
|
49
|
+
compatibleScaffolds: string[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type RuntimeIncompatError = {
|
|
53
|
+
type: 'runtime-incompat';
|
|
54
|
+
pack: string;
|
|
55
|
+
activeScaffold: string;
|
|
56
|
+
missingRuntime: TemplateCapability;
|
|
57
|
+
providedRuntime: TemplateCapability[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type CircularError = {
|
|
61
|
+
type: 'circular';
|
|
62
|
+
cycle: string[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type UnknownPackError = {
|
|
66
|
+
type: 'unknown-pack';
|
|
67
|
+
pack: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type ResolverError =
|
|
71
|
+
| MissingCapabilityError
|
|
72
|
+
| ExclusiveConflictError
|
|
73
|
+
| ScaffoldIncompatError
|
|
74
|
+
| RuntimeIncompatError
|
|
75
|
+
| CircularError
|
|
76
|
+
| UnknownPackError;
|
|
77
|
+
|
|
78
|
+
export type Result<TData, TError> =
|
|
79
|
+
| {
|
|
80
|
+
ok: true;
|
|
81
|
+
data: TData;
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
ok: false;
|
|
85
|
+
error: TError;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function unique(values: readonly string[]): string[] {
|
|
89
|
+
return [...new Set(values)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function providedCapabilities(manifest: PackManifest): ReadonlySet<PackCapability> {
|
|
93
|
+
return new Set(manifest.provides);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function packProvides(entry: PackRegistryEntry, capability: PackCapability): boolean {
|
|
97
|
+
return providedCapabilities(entry.manifest).has(capability);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function findProviders(
|
|
101
|
+
registry: ResolverRegistry,
|
|
102
|
+
capability: PackCapability,
|
|
103
|
+
names: Iterable<string> = registry.packs.keys(),
|
|
104
|
+
): string[] {
|
|
105
|
+
const providers: string[] = [];
|
|
106
|
+
|
|
107
|
+
for (const name of names) {
|
|
108
|
+
const entry = registry.packs.get(name);
|
|
109
|
+
if (entry && packProvides(entry, capability)) {
|
|
110
|
+
providers.push(name);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return providers.sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function firstScaffoldError(
|
|
118
|
+
packName: string,
|
|
119
|
+
manifest: PackManifest,
|
|
120
|
+
activeTemplate: ResolverTemplate,
|
|
121
|
+
): ScaffoldIncompatError | RuntimeIncompatError | undefined {
|
|
122
|
+
if (
|
|
123
|
+
manifest.compatible_scaffolds.length > 0 &&
|
|
124
|
+
!manifest.compatible_scaffolds.includes(activeTemplate.name)
|
|
125
|
+
) {
|
|
126
|
+
return {
|
|
127
|
+
type: 'scaffold-incompat',
|
|
128
|
+
pack: packName,
|
|
129
|
+
activeScaffold: activeTemplate.name,
|
|
130
|
+
compatibleScaffolds: [...manifest.compatible_scaffolds],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const providedRuntime = new Set(activeTemplate.provides);
|
|
135
|
+
for (const requiredRuntime of manifest.requires_runtime) {
|
|
136
|
+
if (!providedRuntime.has(requiredRuntime)) {
|
|
137
|
+
return {
|
|
138
|
+
type: 'runtime-incompat',
|
|
139
|
+
pack: packName,
|
|
140
|
+
activeScaffold: activeTemplate.name,
|
|
141
|
+
missingRuntime: requiredRuntime,
|
|
142
|
+
providedRuntime: [...activeTemplate.provides],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function declaredConflict(
|
|
151
|
+
left: PackRegistryEntry,
|
|
152
|
+
right: PackRegistryEntry,
|
|
153
|
+
): PackCapability | undefined {
|
|
154
|
+
const rightProvides = providedCapabilities(right.manifest);
|
|
155
|
+
for (const capability of left.manifest.conflicts) {
|
|
156
|
+
if (rightProvides.has(capability)) {
|
|
157
|
+
return capability;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const leftProvides = providedCapabilities(left.manifest);
|
|
162
|
+
for (const capability of right.manifest.conflicts) {
|
|
163
|
+
if (leftProvides.has(capability)) {
|
|
164
|
+
return capability;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function exclusiveConflict(
|
|
172
|
+
left: PackRegistryEntry,
|
|
173
|
+
right: PackRegistryEntry,
|
|
174
|
+
): PackCapability | undefined {
|
|
175
|
+
const rightProvides = providedCapabilities(right.manifest);
|
|
176
|
+
|
|
177
|
+
for (const capability of left.manifest.provides) {
|
|
178
|
+
if (EXCLUSIVE_CAPABILITIES.has(capability) && rightProvides.has(capability)) {
|
|
179
|
+
return capability;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkPairConflicts(
|
|
187
|
+
left: PackRegistryEntry,
|
|
188
|
+
right: PackRegistryEntry,
|
|
189
|
+
): ExclusiveConflictError | undefined {
|
|
190
|
+
const explicit = declaredConflict(left, right);
|
|
191
|
+
if (explicit) {
|
|
192
|
+
return {
|
|
193
|
+
type: 'exclusive-conflict',
|
|
194
|
+
capability: explicit,
|
|
195
|
+
packs: [left.name, right.name],
|
|
196
|
+
source: 'declared-conflict',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const exclusive = exclusiveConflict(left, right);
|
|
201
|
+
if (exclusive) {
|
|
202
|
+
return {
|
|
203
|
+
type: 'exclusive-conflict',
|
|
204
|
+
capability: exclusive,
|
|
205
|
+
packs: [left.name, right.name],
|
|
206
|
+
source: 'exclusive-capability',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function sortInstallNames(
|
|
214
|
+
requestedToInstall: readonly string[],
|
|
215
|
+
dependencies: ReadonlyMap<string, ReadonlySet<string>>,
|
|
216
|
+
): Result<string[], CircularError> {
|
|
217
|
+
const sorted: string[] = [];
|
|
218
|
+
const state = new Map<string, 'visiting' | 'visited'>();
|
|
219
|
+
|
|
220
|
+
function visit(name: string, path: string[]): CircularError | undefined {
|
|
221
|
+
const existingState = state.get(name);
|
|
222
|
+
if (existingState === 'visited') {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (existingState === 'visiting') {
|
|
227
|
+
const cycleStart = path.indexOf(name);
|
|
228
|
+
return {
|
|
229
|
+
type: 'circular',
|
|
230
|
+
cycle: [...path.slice(cycleStart), name],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
state.set(name, 'visiting');
|
|
235
|
+
const deps = [...(dependencies.get(name) ?? [])].sort();
|
|
236
|
+
for (const dependency of deps) {
|
|
237
|
+
const cycle = visit(dependency, [...path, dependency]);
|
|
238
|
+
if (cycle) {
|
|
239
|
+
return cycle;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
state.set(name, 'visited');
|
|
243
|
+
sorted.push(name);
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const name of requestedToInstall) {
|
|
248
|
+
const cycle = visit(name, [name]);
|
|
249
|
+
if (cycle) {
|
|
250
|
+
return { ok: false, error: cycle };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { ok: true, data: sorted };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function resolveInstallPlan(
|
|
258
|
+
requestedPacks: readonly string[],
|
|
259
|
+
installedPacks: readonly string[],
|
|
260
|
+
registry: ResolverRegistry,
|
|
261
|
+
activeTemplate: ResolverTemplate,
|
|
262
|
+
): Result<InstallPlan, ResolverError> {
|
|
263
|
+
const requested = unique(requestedPacks);
|
|
264
|
+
const installed = unique(installedPacks);
|
|
265
|
+
const installedSet = new Set(installed);
|
|
266
|
+
|
|
267
|
+
for (const name of [...requested, ...installed]) {
|
|
268
|
+
if (!registry.packs.has(name)) {
|
|
269
|
+
return { ok: false, error: { type: 'unknown-pack', pack: name } };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const alreadyInstalled = requested.filter((name) => installedSet.has(name));
|
|
274
|
+
const requestedToInstall = requested.filter((name) => !installedSet.has(name));
|
|
275
|
+
|
|
276
|
+
if (requestedToInstall.length === 0) {
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
data: {
|
|
280
|
+
packs: [],
|
|
281
|
+
alreadyInstalled,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const name of requestedToInstall) {
|
|
287
|
+
const entry = registry.packs.get(name);
|
|
288
|
+
if (!entry) {
|
|
289
|
+
return { ok: false, error: { type: 'unknown-pack', pack: name } };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const compatibilityError = firstScaffoldError(name, entry.manifest, activeTemplate);
|
|
293
|
+
if (compatibilityError) {
|
|
294
|
+
return { ok: false, error: compatibilityError };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const finalNames = [...installed, ...requestedToInstall];
|
|
299
|
+
for (let leftIndex = 0; leftIndex < finalNames.length; leftIndex += 1) {
|
|
300
|
+
for (let rightIndex = leftIndex + 1; rightIndex < finalNames.length; rightIndex += 1) {
|
|
301
|
+
const leftName = finalNames[leftIndex];
|
|
302
|
+
const rightName = finalNames[rightIndex];
|
|
303
|
+
const pairIncludesNew = !installedSet.has(leftName) || !installedSet.has(rightName);
|
|
304
|
+
if (!pairIncludesNew) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const left = registry.packs.get(leftName);
|
|
309
|
+
const right = registry.packs.get(rightName);
|
|
310
|
+
if (!left || !right) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
error: {
|
|
314
|
+
type: 'unknown-pack',
|
|
315
|
+
pack: left ? rightName : leftName,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const conflict = checkPairConflicts(left, right);
|
|
321
|
+
if (conflict) {
|
|
322
|
+
return { ok: false, error: conflict };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const dependencyEdges = new Map<string, Set<string>>();
|
|
328
|
+
|
|
329
|
+
for (const name of requestedToInstall) {
|
|
330
|
+
const entry = registry.packs.get(name);
|
|
331
|
+
if (!entry) {
|
|
332
|
+
return { ok: false, error: { type: 'unknown-pack', pack: name } };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
dependencyEdges.set(name, new Set<string>());
|
|
336
|
+
for (const capability of entry.manifest.requires) {
|
|
337
|
+
const installedProviders = findProviders(registry, capability, installed);
|
|
338
|
+
const requestedProviders = findProviders(registry, capability, requestedToInstall);
|
|
339
|
+
|
|
340
|
+
if (installedProviders.length === 0 && requestedProviders.length === 0) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
error: {
|
|
344
|
+
type: 'missing-capability',
|
|
345
|
+
pack: name,
|
|
346
|
+
capability,
|
|
347
|
+
providers: findProviders(registry, capability),
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (installedProviders.length === 0) {
|
|
353
|
+
for (const provider of requestedProviders) {
|
|
354
|
+
dependencyEdges.get(name)?.add(provider);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const sortedNames = sortInstallNames(requestedToInstall, dependencyEdges);
|
|
361
|
+
if (!sortedNames.ok) {
|
|
362
|
+
return sortedNames;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
ok: true,
|
|
367
|
+
data: {
|
|
368
|
+
packs: sortedNames.data.map((name) => {
|
|
369
|
+
const entry = registry.packs.get(name);
|
|
370
|
+
if (!entry) {
|
|
371
|
+
throw new Error(`Resolved unknown pack "${name}"`);
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
name,
|
|
375
|
+
manifest: entry.manifest,
|
|
376
|
+
};
|
|
377
|
+
}),
|
|
378
|
+
alreadyInstalled,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import type { PackManifest } from '@forgeailab/spark-schema';
|
|
4
|
+
|
|
5
|
+
type RuntimePackage = NonNullable<PackManifest['runtime_package']>;
|
|
6
|
+
|
|
7
|
+
type PackageJson = {
|
|
8
|
+
dependencies?: Record<string, string>;
|
|
9
|
+
devDependencies?: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stringRecord(value: unknown): Record<string, string> | undefined {
|
|
17
|
+
if (!isRecord(value)) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const entries = Object.entries(value).filter(
|
|
22
|
+
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
23
|
+
);
|
|
24
|
+
return Object.fromEntries(entries);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
const info = await stat(path);
|
|
30
|
+
return info.isFile();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function helperDirectoryName(packageName: string): string {
|
|
40
|
+
return packageName.split('/').at(-1) ?? packageName;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function packageNameFromSpecifier(specifier: string): string {
|
|
44
|
+
const trimmed = specifier.trim();
|
|
45
|
+
if (trimmed.startsWith('@')) {
|
|
46
|
+
const slashIndex = trimmed.indexOf('/');
|
|
47
|
+
if (slashIndex === -1) {
|
|
48
|
+
return trimmed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const versionIndex = trimmed.indexOf('@', slashIndex + 1);
|
|
52
|
+
return versionIndex === -1 ? trimmed : trimmed.slice(0, versionIndex);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const versionIndex = trimmed.indexOf('@');
|
|
56
|
+
return versionIndex === -1 ? trimmed : trimmed.slice(0, versionIndex);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readPackageJson(projectRoot: string): Promise<PackageJson> {
|
|
60
|
+
let raw: string;
|
|
61
|
+
try {
|
|
62
|
+
raw = await readFile(join(projectRoot, 'package.json'), 'utf8');
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
71
|
+
if (!isRecord(parsed)) {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
dependencies: stringRecord(parsed.dependencies),
|
|
77
|
+
devDependencies: stringRecord(parsed.devDependencies),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function assertRuntimeHelperNotRedeclared(packName: string, manifest: PackManifest): void {
|
|
82
|
+
const runtimePackage = manifest.runtime_package;
|
|
83
|
+
if (!runtimePackage) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const duplicate = (manifest.dependencies?.runtime ?? []).find(
|
|
88
|
+
(specifier) => packageNameFromSpecifier(specifier) === runtimePackage.package,
|
|
89
|
+
);
|
|
90
|
+
if (!duplicate) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${packName} declares runtime helper ${runtimePackage.package} in both [runtime_package] and [dependencies].runtime (${duplicate}). Decision 6 requires declaring the helper only in [runtime_package].`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function resolveRuntimeHelper(manifest: PackManifest): Promise<string | undefined> {
|
|
100
|
+
const runtimePackage = manifest.runtime_package;
|
|
101
|
+
if (!runtimePackage) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sparkRoot = process.env.SPARK_ROOT?.trim();
|
|
106
|
+
if (sparkRoot) {
|
|
107
|
+
const helperDir = resolve(sparkRoot, 'libs', helperDirectoryName(runtimePackage.package));
|
|
108
|
+
if (await fileExists(join(helperDir, 'package.json'))) {
|
|
109
|
+
return `file:${helperDir}`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return `${runtimePackage.package}@${runtimePackage.version}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function installedRuntimeHelperSpecifier(
|
|
117
|
+
projectRoot: string,
|
|
118
|
+
runtimePackage: RuntimePackage,
|
|
119
|
+
): Promise<string | undefined> {
|
|
120
|
+
const packageJson = await readPackageJson(projectRoot);
|
|
121
|
+
return (
|
|
122
|
+
packageJson.dependencies?.[runtimePackage.package] ??
|
|
123
|
+
packageJson.devDependencies?.[runtimePackage.package]
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function formatResolvedRuntimeHelper(
|
|
128
|
+
projectRoot: string,
|
|
129
|
+
runtimePackage: RuntimePackage,
|
|
130
|
+
): Promise<string> {
|
|
131
|
+
return (await installedRuntimeHelperSpecifier(projectRoot, runtimePackage)) ?? 'not installed';
|
|
132
|
+
}
|