@ontrails/trails 1.0.0-beta.2 → 1.0.0-beta.22

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.
Files changed (150) hide show
  1. package/CHANGELOG.md +647 -0
  2. package/README.md +26 -0
  3. package/package.json +28 -7
  4. package/src/app.ts +86 -2
  5. package/src/clack.ts +22 -0
  6. package/src/cli.ts +330 -11
  7. package/src/completions.ts +240 -0
  8. package/src/lifecycle-source-io.ts +33 -0
  9. package/src/load-app-mirror.ts +202 -0
  10. package/src/local-state-io.ts +153 -0
  11. package/src/mcp-app.ts +30 -0
  12. package/src/mcp-options.ts +77 -0
  13. package/src/mcp.ts +8 -0
  14. package/src/project-writes.ts +377 -0
  15. package/src/release/bindings.ts +39 -0
  16. package/src/release/check.ts +818 -0
  17. package/src/release/config.ts +63 -0
  18. package/src/release/contract-facts.ts +425 -0
  19. package/src/release/index.ts +85 -0
  20. package/src/release/native-bun-publish.ts +651 -0
  21. package/src/release/native-bun-registry.ts +350 -0
  22. package/src/release/packed-artifacts-smoke.ts +236 -0
  23. package/src/release/smoke.ts +46 -0
  24. package/src/release/wayfinder-dogfood-smoke.ts +226 -0
  25. package/src/retired-topo-command.ts +36 -0
  26. package/src/run-adapter-check.ts +76 -0
  27. package/src/run-collision.ts +126 -0
  28. package/src/run-completions-install.ts +179 -0
  29. package/src/run-example.ts +149 -0
  30. package/src/run-examples.ts +148 -0
  31. package/src/run-quiet.ts +75 -0
  32. package/src/run-release-check.ts +74 -0
  33. package/src/run-trace.ts +273 -0
  34. package/src/run-warden.ts +39 -0
  35. package/src/run-watch.ts +432 -0
  36. package/src/scaffold-version-sync.ts +183 -0
  37. package/src/scaffold-versions.generated.ts +12 -0
  38. package/src/trails/adapter-check.ts +244 -0
  39. package/src/trails/add-surface.ts +94 -40
  40. package/src/trails/add-trail.ts +79 -41
  41. package/src/trails/add-verify.ts +95 -25
  42. package/src/trails/compile.ts +67 -0
  43. package/src/trails/completions-complete.ts +165 -0
  44. package/src/trails/completions.ts +47 -0
  45. package/src/trails/create-adapter.ts +1084 -0
  46. package/src/trails/create-scaffold.ts +399 -104
  47. package/src/trails/create-versions.ts +62 -0
  48. package/src/trails/create.ts +185 -71
  49. package/src/trails/deprecate.ts +59 -0
  50. package/src/trails/dev-clean.ts +82 -0
  51. package/src/trails/dev-reset.ts +50 -0
  52. package/src/trails/dev-stats.ts +72 -0
  53. package/src/trails/dev-support.ts +340 -0
  54. package/src/trails/doctor.ts +56 -0
  55. package/src/trails/draft-promote.ts +949 -0
  56. package/src/trails/guide.ts +74 -68
  57. package/src/trails/load-app.ts +1143 -15
  58. package/src/trails/project.ts +17 -3
  59. package/src/trails/release-check.ts +104 -0
  60. package/src/trails/release-smoke.ts +48 -0
  61. package/src/trails/revise.ts +53 -0
  62. package/src/trails/root-dir.ts +21 -0
  63. package/src/trails/run-example.ts +491 -0
  64. package/src/trails/run-examples.ts +145 -0
  65. package/src/trails/run.ts +410 -0
  66. package/src/trails/scaffold-json.ts +58 -0
  67. package/src/trails/survey.ts +881 -226
  68. package/src/trails/topo-activation.ts +385 -0
  69. package/src/trails/topo-constants.ts +2 -0
  70. package/src/trails/topo-history.ts +47 -0
  71. package/src/trails/topo-output-schemas.ts +248 -0
  72. package/src/trails/topo-pin.ts +52 -0
  73. package/src/trails/topo-read-support.ts +313 -0
  74. package/src/trails/topo-reports.ts +807 -0
  75. package/src/trails/topo-store-support.ts +174 -0
  76. package/src/trails/topo-support.ts +220 -0
  77. package/src/trails/topo-unpin.ts +61 -0
  78. package/src/trails/topo.ts +106 -0
  79. package/src/trails/validate.ts +38 -0
  80. package/src/trails/version-lifecycle-support.ts +945 -0
  81. package/src/trails/warden-guide.ts +129 -0
  82. package/src/trails/warden.ts +165 -58
  83. package/src/versions.ts +31 -0
  84. package/.turbo/turbo-build.log +0 -1
  85. package/.turbo/turbo-lint.log +0 -3
  86. package/.turbo/turbo-typecheck.log +0 -1
  87. package/__tests__/examples.test.ts +0 -6
  88. package/dist/bin/trails.d.ts +0 -3
  89. package/dist/bin/trails.d.ts.map +0 -1
  90. package/dist/bin/trails.js +0 -4
  91. package/dist/bin/trails.js.map +0 -1
  92. package/dist/src/app.d.ts +0 -2
  93. package/dist/src/app.d.ts.map +0 -1
  94. package/dist/src/app.js +0 -11
  95. package/dist/src/app.js.map +0 -1
  96. package/dist/src/clack.d.ts +0 -9
  97. package/dist/src/clack.d.ts.map +0 -1
  98. package/dist/src/clack.js +0 -62
  99. package/dist/src/clack.js.map +0 -1
  100. package/dist/src/cli.d.ts +0 -2
  101. package/dist/src/cli.d.ts.map +0 -1
  102. package/dist/src/cli.js +0 -13
  103. package/dist/src/cli.js.map +0 -1
  104. package/dist/src/trails/add-surface.d.ts +0 -13
  105. package/dist/src/trails/add-surface.d.ts.map +0 -1
  106. package/dist/src/trails/add-surface.js +0 -88
  107. package/dist/src/trails/add-surface.js.map +0 -1
  108. package/dist/src/trails/add-trail.d.ts +0 -11
  109. package/dist/src/trails/add-trail.d.ts.map +0 -1
  110. package/dist/src/trails/add-trail.js +0 -85
  111. package/dist/src/trails/add-trail.js.map +0 -1
  112. package/dist/src/trails/add-verify.d.ts +0 -10
  113. package/dist/src/trails/add-verify.d.ts.map +0 -1
  114. package/dist/src/trails/add-verify.js +0 -67
  115. package/dist/src/trails/add-verify.js.map +0 -1
  116. package/dist/src/trails/create-scaffold.d.ts +0 -15
  117. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  118. package/dist/src/trails/create-scaffold.js +0 -288
  119. package/dist/src/trails/create-scaffold.js.map +0 -1
  120. package/dist/src/trails/create.d.ts +0 -22
  121. package/dist/src/trails/create.d.ts.map +0 -1
  122. package/dist/src/trails/create.js +0 -121
  123. package/dist/src/trails/create.js.map +0 -1
  124. package/dist/src/trails/guide.d.ts +0 -11
  125. package/dist/src/trails/guide.d.ts.map +0 -1
  126. package/dist/src/trails/guide.js +0 -80
  127. package/dist/src/trails/guide.js.map +0 -1
  128. package/dist/src/trails/load-app.d.ts +0 -4
  129. package/dist/src/trails/load-app.d.ts.map +0 -1
  130. package/dist/src/trails/load-app.js +0 -24
  131. package/dist/src/trails/load-app.js.map +0 -1
  132. package/dist/src/trails/project.d.ts +0 -8
  133. package/dist/src/trails/project.d.ts.map +0 -1
  134. package/dist/src/trails/project.js +0 -43
  135. package/dist/src/trails/project.js.map +0 -1
  136. package/dist/src/trails/survey.d.ts +0 -33
  137. package/dist/src/trails/survey.d.ts.map +0 -1
  138. package/dist/src/trails/survey.js +0 -225
  139. package/dist/src/trails/survey.js.map +0 -1
  140. package/dist/src/trails/warden.d.ts +0 -19
  141. package/dist/src/trails/warden.d.ts.map +0 -1
  142. package/dist/src/trails/warden.js +0 -88
  143. package/dist/src/trails/warden.js.map +0 -1
  144. package/dist/tsconfig.tsbuildinfo +0 -1
  145. package/src/__tests__/create.test.ts +0 -349
  146. package/src/__tests__/guide.test.ts +0 -91
  147. package/src/__tests__/load-app.test.ts +0 -15
  148. package/src/__tests__/survey.test.ts +0 -161
  149. package/src/__tests__/warden.test.ts +0 -74
  150. package/tsconfig.json +0 -9
@@ -0,0 +1,410 @@
1
+ /**
2
+ * `run` trail -- Direct trail invocation by ID.
3
+ *
4
+ * Resolves a trail in the current app's topo and executes it through the
5
+ * shared `run()` pipeline from `@ontrails/core`. The CLI surface drives this
6
+ * trail with `trails run <id> [--app <name>] [inline-json]`.
7
+ *
8
+ * Resolution order:
9
+ *
10
+ * 1. If `module` is provided, load that module directly. This preserves the
11
+ * single-app code path used by `testExamples` and tests that hand-build a
12
+ * workspace fixture.
13
+ * 2. Otherwise, build the workspace trail-id index via
14
+ * {@link buildWorkspaceTrailIndex}.
15
+ * - If `app` is provided, resolve `app -> appDir -> module` and load that
16
+ * app's topo. The trail id must exist in the chosen app.
17
+ * - Else if the trail id has exactly one owner in the workspace index, use
18
+ * that owner.
19
+ * - Else if the trail id collides across multiple apps, return
20
+ * `Result.err(AmbiguousError)`. The CLI surface decides whether to prompt
21
+ * (TTY) or surface the error (non-TTY); the trail itself stays
22
+ * surface-agnostic.
23
+ * - Else return `Result.err(NotFoundError)`.
24
+ *
25
+ * The trail's output keeps a typed discriminator around the heterogeneous
26
+ * inner trail value. The value itself remains `unknown` because direct
27
+ * invocation can target any trail in the loaded app.
28
+ */
29
+
30
+ import { join } from 'node:path';
31
+
32
+ import {
33
+ AmbiguousError,
34
+ NotFoundError,
35
+ Result,
36
+ run,
37
+ trail,
38
+ } from '@ontrails/core';
39
+ import { buildWorkspaceTrailIndex } from '@ontrails/topographer';
40
+ import type {
41
+ WorkspaceTrailCollision,
42
+ WorkspaceTrailEntry,
43
+ } from '@ontrails/topographer';
44
+ import { z } from 'zod';
45
+
46
+ import {
47
+ createIsolatedExampleRoot,
48
+ writeIsolatedExampleJsonFile,
49
+ writeIsolatedExampleTextFile,
50
+ } from '../local-state-io.js';
51
+
52
+ import { tryLoadFreshAppLease } from './load-app.js';
53
+ import { resolveTrailRootDir } from './root-dir.js';
54
+ import { createIsolatedExampleInput } from './topo-support.js';
55
+
56
+ export const INNER_TRAIL_RESULT_KIND = 'inner-trail-result' as const;
57
+
58
+ export const innerTrailResultSchema = z.object({
59
+ kind: z.literal(INNER_TRAIL_RESULT_KIND),
60
+ trailId: z.string(),
61
+ value: z.unknown(),
62
+ });
63
+
64
+ export type InnerTrailResult = z.infer<typeof innerTrailResultSchema>;
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Resolution outcomes
68
+ // ---------------------------------------------------------------------------
69
+
70
+ type ResolveAppOutcome =
71
+ | { readonly kind: 'resolved'; readonly module: string }
72
+ | {
73
+ readonly kind: 'ambiguous';
74
+ readonly candidates: readonly string[];
75
+ }
76
+ | {
77
+ readonly kind: 'wrong-app';
78
+ readonly actualOwner: string;
79
+ readonly requestedApp: string;
80
+ }
81
+ | { readonly kind: 'not-found'; readonly requestedApp?: string | undefined };
82
+
83
+ const collectCandidates = (
84
+ trailId: string,
85
+ index: Readonly<Record<string, WorkspaceTrailEntry>>,
86
+ collision: WorkspaceTrailCollision | undefined
87
+ ): readonly string[] => {
88
+ if (collision !== undefined) {
89
+ return collision.apps;
90
+ }
91
+ const sole = index[trailId];
92
+ return sole === undefined ? [] : [sole.appName];
93
+ };
94
+
95
+ const findOwner = (
96
+ trailId: string,
97
+ index: Readonly<Record<string, WorkspaceTrailEntry>>,
98
+ collision: WorkspaceTrailCollision | undefined,
99
+ appName: string
100
+ ): WorkspaceTrailEntry | undefined => {
101
+ const sole = index[trailId];
102
+ if (sole?.appName === appName) {
103
+ return sole;
104
+ }
105
+ return collision?.owners.find((owner) => owner.appName === appName);
106
+ };
107
+
108
+ const resolveOwningAppViaIndex = async (
109
+ workspaceRoot: string,
110
+ trailId: string,
111
+ appOverride: string | undefined
112
+ ): Promise<ResolveAppOutcome> => {
113
+ const result = await buildWorkspaceTrailIndex({ cwd: workspaceRoot });
114
+
115
+ const matchingCollision = result.collisions.find(
116
+ (entry) => entry.trailId === trailId
117
+ );
118
+
119
+ // Honor an explicit app override when provided.
120
+ if (appOverride !== undefined) {
121
+ const candidatesForId = collectCandidates(
122
+ trailId,
123
+ result.index,
124
+ matchingCollision
125
+ );
126
+ if (candidatesForId.length > 0 && !candidatesForId.includes(appOverride)) {
127
+ // Sole-owner mismatch: trail is owned uniquely by another app.
128
+ // Surface a wrong-app outcome so the user sees the actual owner,
129
+ // not an "ambiguous" message that doesn't apply.
130
+ if (candidatesForId.length === 1) {
131
+ const [actualOwner] = candidatesForId;
132
+ if (actualOwner !== undefined) {
133
+ return {
134
+ actualOwner,
135
+ kind: 'wrong-app',
136
+ requestedApp: appOverride,
137
+ };
138
+ }
139
+ }
140
+ return { candidates: candidatesForId, kind: 'ambiguous' };
141
+ }
142
+ const owner = findOwner(
143
+ trailId,
144
+ result.index,
145
+ matchingCollision,
146
+ appOverride
147
+ );
148
+ if (owner === undefined) {
149
+ return { kind: 'not-found', requestedApp: appOverride };
150
+ }
151
+ return { kind: 'resolved', module: owner.modulePath };
152
+ }
153
+
154
+ if (matchingCollision !== undefined) {
155
+ return {
156
+ candidates: matchingCollision.apps,
157
+ kind: 'ambiguous',
158
+ };
159
+ }
160
+
161
+ const owner = result.index[trailId];
162
+ if (owner === undefined) {
163
+ return { kind: 'not-found' };
164
+ }
165
+
166
+ return { kind: 'resolved', module: owner.modulePath };
167
+ };
168
+
169
+ const ambiguousMessage = (
170
+ trailId: string,
171
+ candidates: readonly string[]
172
+ ): string =>
173
+ `Trail ID '${trailId}' exists in apps: ${candidates.join(', ')}. Re-run with --app <name>.`;
174
+
175
+ export const resolveRunModulePath = async (
176
+ rootDir: string,
177
+ module: string | undefined,
178
+ trailId: string,
179
+ app: string | undefined
180
+ ): Promise<Result<string, Error>> => {
181
+ if (module !== undefined) {
182
+ return Result.ok(module);
183
+ }
184
+
185
+ const outcome = await resolveOwningAppViaIndex(rootDir, trailId, app);
186
+ if (outcome.kind === 'resolved') {
187
+ return Result.ok(outcome.module);
188
+ }
189
+ if (outcome.kind === 'ambiguous') {
190
+ return Result.err(
191
+ new AmbiguousError(ambiguousMessage(trailId, outcome.candidates), {
192
+ context: { candidates: outcome.candidates, trailId },
193
+ })
194
+ );
195
+ }
196
+ if (outcome.kind === 'wrong-app') {
197
+ return Result.err(
198
+ new NotFoundError(
199
+ `Trail '${trailId}' is owned by '${outcome.actualOwner}', not '${outcome.requestedApp}'.`,
200
+ {
201
+ context: {
202
+ actualOwner: outcome.actualOwner,
203
+ requestedApp: outcome.requestedApp,
204
+ trailId,
205
+ },
206
+ }
207
+ )
208
+ );
209
+ }
210
+ const appContext =
211
+ outcome.requestedApp === undefined
212
+ ? ''
213
+ : ` for app '${outcome.requestedApp}'`;
214
+ return Result.err(
215
+ new NotFoundError(
216
+ `Trail '${trailId}' was not found${appContext} in any workspace app under ${rootDir}.`,
217
+ {
218
+ context: {
219
+ ...(outcome.requestedApp === undefined
220
+ ? {}
221
+ : { requestedApp: outcome.requestedApp }),
222
+ rootDir,
223
+ trailId,
224
+ },
225
+ }
226
+ )
227
+ );
228
+ };
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Ambiguous-example workspace fixture
232
+ // ---------------------------------------------------------------------------
233
+
234
+ const buildStubTopoSource = (appName: string): string =>
235
+ [
236
+ `const sharedIds = ['shared.id'];`,
237
+ `export const app = {`,
238
+ ` name: '${appName}',`,
239
+ ` ids: () => sharedIds,`,
240
+ `};`,
241
+ '',
242
+ ].join('\n');
243
+
244
+ const writeAmbiguousWorkspaceFixture = (workspaceRoot: string): void => {
245
+ // Root package.json declaring two workspace apps.
246
+ writeIsolatedExampleJsonFile(workspaceRoot, 'package.json', {
247
+ name: 'run-ambiguous-fixture',
248
+ private: true,
249
+ type: 'module',
250
+ workspaces: ['apps/*'],
251
+ });
252
+
253
+ // Each app declares a Trails-app shape so discovery picks it up. The
254
+ // discovery layer only calls `topo.ids()` and reads `topo.name`, so a
255
+ // hand-rolled stub satisfies the `isTopo` shape without pulling in
256
+ // `@ontrails/core` from a temp directory that has no node_modules.
257
+ for (const appName of ['app-a', 'app-b'] as const) {
258
+ writeIsolatedExampleJsonFile(
259
+ workspaceRoot,
260
+ join('apps', appName, 'package.json'),
261
+ {
262
+ name: appName,
263
+ private: true,
264
+ trails: { module: 'src/app.ts' },
265
+ type: 'module',
266
+ }
267
+ );
268
+ writeIsolatedExampleTextFile(
269
+ workspaceRoot,
270
+ join('apps', appName, 'src/app.ts'),
271
+ buildStubTopoSource(appName)
272
+ );
273
+ }
274
+ };
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Example input helpers
278
+ // ---------------------------------------------------------------------------
279
+
280
+ const buildHappyExampleInput = (): {
281
+ readonly input: { readonly module: string; readonly rootDir: string };
282
+ readonly id: string;
283
+ readonly module: string;
284
+ readonly rootDir: string;
285
+ } => {
286
+ const isolated = createIsolatedExampleInput('run-happy');
287
+ return {
288
+ id: 'survey.brief',
289
+ input: { module: isolated.module, rootDir: isolated.rootDir },
290
+ module: isolated.module,
291
+ rootDir: isolated.rootDir,
292
+ };
293
+ };
294
+
295
+ const buildNotFoundExampleInput = (): {
296
+ readonly id: string;
297
+ readonly module: string;
298
+ readonly rootDir: string;
299
+ } => ({
300
+ ...createIsolatedExampleInput('run-not-found'),
301
+ id: 'does.not.exist',
302
+ });
303
+
304
+ const uniqueAmbiguousExampleName = (): string =>
305
+ `run-ambiguous-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
306
+
307
+ const buildAmbiguousExampleInput = (): {
308
+ readonly id: string;
309
+ readonly rootDir: string;
310
+ } => {
311
+ const root = createIsolatedExampleRoot(uniqueAmbiguousExampleName());
312
+ writeAmbiguousWorkspaceFixture(root);
313
+ return { id: 'shared.id', rootDir: root };
314
+ };
315
+
316
+ const runTrailInputSchema = z.object({
317
+ app: z
318
+ .string()
319
+ .optional()
320
+ .describe(
321
+ 'Workspace app to resolve the trail ID against; required when the ID is exposed by more than one app'
322
+ ),
323
+ id: z.string().describe('Trail ID to invoke'),
324
+ input: z
325
+ .unknown()
326
+ .optional()
327
+ .describe(
328
+ 'Parsed input for the resolved trail; the CLI surface JSON.parses the inline argument before passing it through'
329
+ ),
330
+ module: z.string().optional().describe('Path to the app module'),
331
+ rootDir: z.string().optional().describe('Workspace root directory'),
332
+ });
333
+
334
+ type RunTrailInput = z.output<typeof runTrailInputSchema>;
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Trail definition
338
+ // ---------------------------------------------------------------------------
339
+
340
+ export const runTrail = trail('run', {
341
+ args: ['id'],
342
+ blaze: async (input: RunTrailInput, ctx) => {
343
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
344
+ if (rootDirResult.isErr()) {
345
+ return rootDirResult;
346
+ }
347
+ const rootDir = rootDirResult.value;
348
+
349
+ // Single-app back-compat: if the caller provided `module`, trust it.
350
+ const moduleResolution = await resolveRunModulePath(
351
+ rootDir,
352
+ input.module,
353
+ input.id,
354
+ input.app
355
+ );
356
+ if (moduleResolution.isErr()) {
357
+ return moduleResolution;
358
+ }
359
+ const modulePath = moduleResolution.value;
360
+
361
+ const leaseResult = await tryLoadFreshAppLease(modulePath, rootDir);
362
+ if (leaseResult.isErr()) {
363
+ return leaseResult;
364
+ }
365
+ const lease = leaseResult.value;
366
+
367
+ try {
368
+ const result = await run(lease.app, input.id, input.input, {
369
+ ctx: ctx.permit === undefined ? {} : { permit: ctx.permit },
370
+ });
371
+ if (result.isErr()) {
372
+ return Result.err(result.error);
373
+ }
374
+ return Result.ok({
375
+ kind: INNER_TRAIL_RESULT_KIND,
376
+ trailId: input.id,
377
+ value: result.value,
378
+ });
379
+ } finally {
380
+ lease.release();
381
+ }
382
+ },
383
+ description:
384
+ 'Resolve a trail by ID in the current app and execute it through the shared pipeline',
385
+ examples: [
386
+ {
387
+ description:
388
+ 'Resolve and execute a trail by ID, returning the inner trail Result value',
389
+ input: buildHappyExampleInput(),
390
+ name: 'Run trail by ID',
391
+ },
392
+ {
393
+ description: 'Reject an unknown trail ID with NotFoundError',
394
+ error: 'NotFoundError',
395
+ input: buildNotFoundExampleInput(),
396
+ name: 'Reject unknown trail ID',
397
+ },
398
+ {
399
+ description:
400
+ 'Reject an ambiguous trail ID without --app with AmbiguousError so non-TTY callers see exit code 1',
401
+ error: 'AmbiguousError',
402
+ input: buildAmbiguousExampleInput(),
403
+ name: 'Reject ambiguous trail ID without --app',
404
+ },
405
+ ],
406
+ input: runTrailInputSchema,
407
+ intent: 'write',
408
+ output: innerTrailResultSchema,
409
+ permit: { scopes: ['trails:run'] },
410
+ });
@@ -0,0 +1,58 @@
1
+ const packageKeyOrder = [
2
+ 'name',
3
+ 'version',
4
+ 'bin',
5
+ 'type',
6
+ 'scripts',
7
+ 'dependencies',
8
+ 'devDependencies',
9
+ ] as const;
10
+
11
+ const packageMapKeys = new Set<string>([
12
+ 'bin',
13
+ 'dependencies',
14
+ 'devDependencies',
15
+ 'scripts',
16
+ ]);
17
+
18
+ export type ScaffoldPackageJson = Record<string, unknown>;
19
+
20
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
21
+ typeof value === 'object' && value !== null && !Array.isArray(value);
22
+
23
+ const sortRecord = (record: Record<string, unknown>): Record<string, unknown> =>
24
+ Object.fromEntries(
25
+ Object.entries(record).toSorted(([left], [right]) =>
26
+ left.localeCompare(right)
27
+ )
28
+ );
29
+
30
+ const normalizePackageValue = (key: string, value: unknown): unknown =>
31
+ packageMapKeys.has(key) && isPlainRecord(value) ? sortRecord(value) : value;
32
+
33
+ export const normalizeScaffoldPackageJson = (
34
+ pkg: ScaffoldPackageJson
35
+ ): ScaffoldPackageJson => {
36
+ const normalized: ScaffoldPackageJson = {};
37
+
38
+ for (const key of packageKeyOrder) {
39
+ if (pkg[key] !== undefined) {
40
+ normalized[key] = normalizePackageValue(key, pkg[key]);
41
+ }
42
+ }
43
+
44
+ for (const key of Object.keys(pkg).toSorted()) {
45
+ if (!(key in normalized)) {
46
+ normalized[key] = normalizePackageValue(key, pkg[key]);
47
+ }
48
+ }
49
+
50
+ return normalized;
51
+ };
52
+
53
+ export const stringifyScaffoldJson = (value: unknown): string =>
54
+ `${JSON.stringify(value, null, 2)}\n`;
55
+
56
+ export const stringifyScaffoldPackageJson = (
57
+ pkg: ScaffoldPackageJson
58
+ ): string => stringifyScaffoldJson(normalizeScaffoldPackageJson(pkg));