@smallpen/local-package 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (37) hide show
  1. package/assets/fonts/SourceSansPro-OFL.txt +46 -0
  2. package/assets/fonts/WorkSans-OFL.txt +46 -0
  3. package/package.json +4 -4
  4. package/runtime/fonts/OFL.txt +46 -0
  5. package/runtime/fonts/sourcesanspro-black.ttf +0 -0
  6. package/runtime/fonts/sourcesanspro-blackitalic.ttf +0 -0
  7. package/runtime/fonts/sourcesanspro-bold.ttf +0 -0
  8. package/runtime/fonts/sourcesanspro-bolditalic.ttf +0 -0
  9. package/runtime/fonts/sourcesanspro-extralight.ttf +0 -0
  10. package/runtime/fonts/sourcesanspro-extralightitalic.ttf +0 -0
  11. package/runtime/fonts/sourcesanspro-italic.ttf +0 -0
  12. package/runtime/fonts/sourcesanspro-light.ttf +0 -0
  13. package/runtime/fonts/sourcesanspro-lightitalic.ttf +0 -0
  14. package/runtime/fonts/sourcesanspro-regular.ttf +0 -0
  15. package/runtime/fonts/sourcesanspro-semibold.ttf +0 -0
  16. package/runtime/fonts/sourcesanspro-semibolditalic.ttf +0 -0
  17. package/runtime/manifest.json +7 -0
  18. package/runtime/render-wasm.js +15 -0
  19. package/runtime/render-wasm.wasm +0 -0
  20. package/runtime/renderer.mjs +169652 -0
  21. package/src/draft-import.mjs +1 -11
  22. package/src/font-convert.mjs +23 -1
  23. package/src/fs-utils.mjs +95 -0
  24. package/src/index.mjs +19 -3
  25. package/src/initialize.mjs +363 -136
  26. package/src/kiwi-runtime.mjs +38 -36
  27. package/src/library-network.mjs +189 -0
  28. package/src/local-package-backend.mjs +215 -24
  29. package/src/local-package.mjs +589 -177
  30. package/src/local-workspace-session.mjs +278 -96
  31. package/src/migrate-themes.mjs +116 -0
  32. package/src/remote-library.mjs +167 -28
  33. package/src/render-fonts.mjs +172 -0
  34. package/src/render-shared.mjs +346 -0
  35. package/src/render.mjs +71 -3557
  36. package/src/workspace.mjs +178 -17
  37. package/src/webp-decode.mjs +0 -73
@@ -1,9 +1,18 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { dirname, join, resolve } from "node:path";
2
3
 
3
- import { SmallPenError } from "@smallpen/core";
4
+ import { followedVariantOperations, SmallPenError } from "@smallpen/core";
4
5
 
5
6
  import { LocalPackageBackend } from "./local-package-backend.mjs";
6
- import { resolveWorkspace } from "./workspace.mjs";
7
+ import {
8
+ localLibraryLocator,
9
+ MAX_LIBRARY_DECLARATIONS,
10
+ resolveWorkspace,
11
+ } from "./workspace.mjs";
12
+
13
+ // A Package in Repair because a linked Foundation or Library is unavailable
14
+ // gets no event when that link is fixed, so its status is resolved again.
15
+ const LINK_REPAIR_RETRY_MS = 1000;
7
16
 
8
17
  function collectStableIds(value, result) {
9
18
  if (Array.isArray(value)) {
@@ -49,22 +58,6 @@ function defaultTokenLibrary() {
49
58
  };
50
59
  }
51
60
 
52
- async function seedDefaultTheme(backend, snapshot) {
53
- if (snapshot.manifest.entries.tokens.length > 0) return snapshot;
54
- await backend.commit({
55
- baseRevision: snapshot.revision,
56
- batchId: `seed-default-theme-${snapshot.revision.slice(0, 12)}`,
57
- operations: [
58
- {
59
- entry: "tokens/tokens.json",
60
- library: defaultTokenLibrary(),
61
- type: "replace-token-library",
62
- },
63
- ],
64
- });
65
- return backend.snapshot;
66
- }
67
-
68
61
  export function reconcilePackageViewState(snapshot, value = {}) {
69
62
  const firstScreenEntry = snapshot.manifest.entries.screens[0];
70
63
  const defaultScreen =
@@ -94,13 +87,21 @@ export function reconcilePackageViewState(snapshot, value = {}) {
94
87
  }
95
88
 
96
89
  export class LocalWorkspaceSession {
90
+ #fetchImpl;
97
91
  #libraryCacheRoot;
98
92
  #listeners = new Set();
93
+ // Opens still running: requests, dependency events and Repair retries.
94
+ #opening = new Set();
99
95
  #packages = new Map();
96
+ // Remote Library snapshots verified by this session, keyed by manifest URL.
97
+ // Status updates after commits reuse them so a slow or unreachable remote
98
+ // never blocks a local write; open() and refresh() read them again.
99
+ #remoteLibraries = new Map();
100
100
 
101
101
  activeLocator;
102
102
 
103
- constructor({ libraryCacheRoot } = {}) {
103
+ constructor({ fetchImpl, libraryCacheRoot } = {}) {
104
+ this.#fetchImpl = fetchImpl;
104
105
  this.#libraryCacheRoot = libraryCacheRoot;
105
106
  }
106
107
 
@@ -120,40 +121,67 @@ export class LocalWorkspaceSession {
120
121
  }));
121
122
  }
122
123
 
123
- async open(locator, viewState = {}) {
124
+ open(locator, viewState = {}) {
125
+ const opening = this.#open(locator, viewState);
126
+ this.#opening.add(opening);
127
+ const settled = () => this.#opening.delete(opening);
128
+ opening.then(settled, settled);
129
+ return opening;
130
+ }
131
+
132
+ async #open(locator, viewState) {
124
133
  const requested = resolve(locator);
125
134
  const existing = this.#find(requested);
126
135
  if (existing) {
127
136
  this.activeLocator = existing.locator;
128
137
  return this.describe(existing.locator);
129
138
  }
130
- const backend = new LocalPackageBackend();
131
- let snapshot = await backend.open(requested);
132
- const duplicate = [...this.#packages.values()].find(
133
- (candidate) =>
134
- candidate.backend.snapshot.runtime.file === snapshot.runtime.file,
135
- );
136
- if (duplicate) {
137
- await backend.close();
138
- throw new SmallPenError(
139
- "duplicate_file_id",
140
- "Another open Package has the same persistent file identity",
141
- {
142
- fileId: snapshot.runtime.file,
139
+ // Opening never writes: a Package without Tokens is served with the
140
+ // default Theme projected in memory and persisted by its first commit.
141
+ const backend = new LocalPackageBackend({
142
+ defaultTokenLibrary: defaultTokenLibrary(),
143
+ });
144
+ let session;
145
+ let opened;
146
+ try {
147
+ const snapshot = await backend.open(requested);
148
+ // A symlinked or non-canonical spelling of an open Package, or a
149
+ // concurrent open of the same one, selects the session already open.
150
+ opened = this.#packages.get(snapshot.locator);
151
+ if (!opened) {
152
+ const duplicate = [...this.#packages.values()].find(
153
+ (candidate) =>
154
+ candidate.backend.snapshot.runtime.file === snapshot.runtime.file,
155
+ );
156
+ if (duplicate) {
157
+ throw new SmallPenError(
158
+ "duplicate_file_id",
159
+ "Another open Package has the same persistent file identity",
160
+ {
161
+ fileId: snapshot.runtime.file,
162
+ locator: snapshot.locator,
163
+ openLocator: duplicate.locator,
164
+ packageId: snapshot.manifest.packageId,
165
+ },
166
+ );
167
+ }
168
+ session = {
169
+ backend,
143
170
  locator: snapshot.locator,
144
- openLocator: duplicate.locator,
145
- packageId: snapshot.manifest.packageId,
146
- },
147
- );
171
+ unsubscribe: undefined,
172
+ statusSequence: 0,
173
+ viewState: reconcilePackageViewState(snapshot, viewState),
174
+ workspaceStatus: structuredClone(backend.status),
175
+ };
176
+ }
177
+ } finally {
178
+ if (!session) await backend.close();
148
179
  }
149
- snapshot = await seedDefaultTheme(backend, snapshot);
150
- const session = {
151
- backend,
152
- locator: snapshot.locator,
153
- unsubscribe: undefined,
154
- viewState: reconcilePackageViewState(snapshot, viewState),
155
- workspaceStatus: structuredClone(backend.status),
156
- };
180
+ if (opened) {
181
+ this.activeLocator = opened.locator;
182
+ return this.describe(opened.locator);
183
+ }
184
+ const { snapshot } = backend;
157
185
  session.unsubscribe = backend.subscribe((event) => {
158
186
  if (event.snapshot) {
159
187
  session.viewState = reconcilePackageViewState(
@@ -161,7 +189,11 @@ export class LocalWorkspaceSession {
161
189
  session.viewState,
162
190
  );
163
191
  }
164
- void this.#updateWorkspaceStatus(session);
192
+ // An external edit can retarget the Foundation or a Library.
193
+ const linked = event.snapshot
194
+ ? this.#openLinkedPackages(event.snapshot)
195
+ : Promise.resolve();
196
+ void linked.then(() => this.#updateWorkspaceStatus(session));
165
197
  this.#emit({ ...event, locator: session.locator });
166
198
  if (
167
199
  event.type === "external-revision" ||
@@ -173,25 +205,8 @@ export class LocalWorkspaceSession {
173
205
  });
174
206
  this.#packages.set(session.locator, session);
175
207
  this.activeLocator ??= session.locator;
176
- if (snapshot.manifest.role === "product") {
177
- const dependency = snapshot.manifest.dependencies[0];
178
- try {
179
- await this.open(join(dirname(snapshot.locator), dependency.path));
180
- } catch {
181
- // Composite status below retains Product and exposes dependency Repair.
182
- }
183
- }
184
- for (const library of snapshot.manifest.libraries ?? []) {
185
- if (library.source.type !== "local") continue;
186
- try {
187
- await this.open(
188
- resolve(dirname(snapshot.locator), library.source.path),
189
- );
190
- } catch {
191
- // Composite status below retains the owner and exposes Library Repair.
192
- }
193
- }
194
- await this.#updateWorkspaceStatus(session);
208
+ await this.#openLinkedPackages(snapshot);
209
+ await this.#updateWorkspaceStatus(session, { reuseRemoteLibraries: false });
195
210
  this.#emit({ locator: session.locator, snapshot, type: "package-opened" });
196
211
  return this.describe(session.locator);
197
212
  }
@@ -219,7 +234,7 @@ export class LocalWorkspaceSession {
219
234
 
220
235
  async refresh(locator) {
221
236
  const session = this.#required(locator);
222
- await this.#updateWorkspaceStatus(session);
237
+ await this.#updateWorkspaceStatus(session, { reuseRemoteLibraries: false });
223
238
  return this.describe(session.locator);
224
239
  }
225
240
 
@@ -244,7 +259,11 @@ export class LocalWorkspaceSession {
244
259
  async commit(locator, batch) {
245
260
  const session = this.#required(locator);
246
261
  this.#assertWorkspaceWritable(session);
262
+ const before = session.backend.snapshot;
247
263
  const result = await session.backend.commit(batch);
264
+ const followed = await this.#followVariants(session, before);
265
+ if (followed.length > 0) result.followedVariants = followed;
266
+ await this.#openLinkedPackages(session.backend.snapshot);
248
267
  await this.#updateWorkspaceStatus(session);
249
268
  session.viewState = reconcilePackageViewState(
250
269
  session.backend.snapshot,
@@ -256,32 +275,41 @@ export class LocalWorkspaceSession {
256
275
  result,
257
276
  type: "package-committed",
258
277
  });
278
+ this.#emitDependencyChanges(session, { type: "package-committed" });
259
279
  return result;
260
280
  }
261
281
 
262
282
  async undo(locator) {
263
283
  const session = this.#required(locator);
264
284
  this.#assertWorkspaceWritable(session);
285
+ const before = session.backend.snapshot;
265
286
  const result = await session.backend.undo();
287
+ const followed = await this.#followVariants(session, before);
288
+ if (followed.length > 0) result.followedVariants = followed;
266
289
  await this.#updateWorkspaceStatus(session);
267
290
  session.viewState = reconcilePackageViewState(
268
291
  session.backend.snapshot,
269
292
  session.viewState,
270
293
  );
271
294
  this.#emit({ locator: session.locator, result, type: "package-undone" });
295
+ this.#emitDependencyChanges(session, { type: "package-undone" });
272
296
  return result;
273
297
  }
274
298
 
275
299
  async redo(locator) {
276
300
  const session = this.#required(locator);
277
301
  this.#assertWorkspaceWritable(session);
302
+ const before = session.backend.snapshot;
278
303
  const result = await session.backend.redo();
304
+ const followed = await this.#followVariants(session, before);
305
+ if (followed.length > 0) result.followedVariants = followed;
279
306
  await this.#updateWorkspaceStatus(session);
280
307
  session.viewState = reconcilePackageViewState(
281
308
  session.backend.snapshot,
282
309
  session.viewState,
283
310
  );
284
311
  this.#emit({ locator: session.locator, result, type: "package-redone" });
312
+ this.#emitDependencyChanges(session, { type: "package-redone" });
285
313
  return result;
286
314
  }
287
315
 
@@ -330,6 +358,8 @@ export class LocalWorkspaceSession {
330
358
 
331
359
  async close(locator) {
332
360
  const session = this.#required(locator);
361
+ clearTimeout(session.repairRetry);
362
+ session.repairRetry = undefined;
333
363
  session.unsubscribe?.();
334
364
  await session.backend.close();
335
365
  this.#packages.delete(session.locator);
@@ -339,8 +369,14 @@ export class LocalWorkspaceSession {
339
369
  this.#emit({ locator: session.locator, type: "package-closed" });
340
370
  }
341
371
 
372
+ // An open still in flight can register a Package (or its Foundation and
373
+ // Libraries) after the loop has passed; its watchers would keep the
374
+ // process alive, so close until no open is pending.
342
375
  async closeAll() {
343
- for (const locator of [...this.#packages.keys()]) await this.close(locator);
376
+ while (this.#packages.size > 0 || this.#opening.size > 0) {
377
+ for (const locator of [...this.#packages.keys()]) await this.close(locator);
378
+ await Promise.allSettled([...this.#opening]);
379
+ }
344
380
  }
345
381
 
346
382
  #find(locator) {
@@ -363,37 +399,178 @@ export class LocalWorkspaceSession {
363
399
  return session;
364
400
  }
365
401
 
366
- #emitDependencyChanges(changed, event) {
367
- const packageId = changed.backend.snapshot?.manifest.packageId;
368
- if (!packageId) return;
369
- for (const session of this.#packages.values()) {
370
- if (session === changed) continue;
371
- if (
372
- session.backend.snapshot.manifest.dependencies?.some(
373
- (dependency) => dependency.packageId === packageId,
374
- ) ||
375
- session.backend.snapshot.manifest.libraries?.some(
376
- (library) => library.packageId === packageId,
377
- )
378
- ) {
379
- void this.#updateWorkspaceStatus(session).then(() => {
380
- this.#emit({
381
- dependencyEvent: event.type,
382
- dependencyLocator: changed.locator,
383
- locator: session.locator,
384
- status: structuredClone(session.workspaceStatus),
385
- type: "package-dependency-changed",
386
- });
402
+ // Opens the Foundation and local Libraries a Package links to, so their
403
+ // external edits reach it. Links that cannot be opened stay in Repair and
404
+ // are retried while the owner's status is not ready. The active Package
405
+ // never changes here.
406
+ async #openLinkedPackages(snapshot) {
407
+ const locators = [];
408
+ if (snapshot.manifest.role === "product") {
409
+ const dependency = snapshot.manifest.dependencies[0];
410
+ locators.push(join(dirname(snapshot.locator), dependency.path));
411
+ }
412
+ // Resolution never follows more declarations than this, so retrying
413
+ // the rest would only spend CPU on every Repair retry.
414
+ for (const library of (snapshot.manifest.libraries ?? []).slice(
415
+ 0,
416
+ MAX_LIBRARY_DECLARATIONS,
417
+ )) {
418
+ if (library.source.type !== "local") continue;
419
+ try {
420
+ locators.push(localLibraryLocator(snapshot.locator, library.source));
421
+ } catch {
422
+ // Composite status exposes the invalid Library source for Repair.
423
+ }
424
+ }
425
+ for (const locator of locators) {
426
+ if (this.#find(locator)) continue;
427
+ const active = this.activeLocator;
428
+ try {
429
+ await this.open(locator);
430
+ } catch {
431
+ // Composite status retains the owner and exposes the Repair.
432
+ } finally {
433
+ this.activeLocator = active ?? this.activeLocator;
434
+ }
435
+ }
436
+ }
437
+
438
+ // A write renamed variant values in a Package other open Packages use:
439
+ // each of those re-points its Instances to the same variants in a batch
440
+ // of its own (its own undo entry), so it never turns stale for a rename.
441
+ // Instances of deleted variants stay for Repair. Returns what followed.
442
+ async #followVariants(session, before) {
443
+ const after = session.backend.snapshot;
444
+ if (!before || !after || before === after) return [];
445
+ const followed = [];
446
+ for (const dependent of this.#dependentSessions(session)) {
447
+ const snapshot = dependent.backend.snapshot;
448
+ if (!snapshot) continue;
449
+ const operations = followedVariantOperations(before, after, snapshot);
450
+ if (operations.length === 0) continue;
451
+ try {
452
+ const result = await dependent.backend.commit({
453
+ baseRevision: snapshot.revision,
454
+ batchId: `follow_variants_${randomUUID()}`,
455
+ operations,
456
+ });
457
+ followed.push({
458
+ batchId: result.batchId,
459
+ instances: operations.length,
460
+ locator: dependent.locator,
461
+ revision: result.revision,
462
+ });
463
+ await this.#updateWorkspaceStatus(dependent);
464
+ this.#emit({
465
+ change: dependent.backend.changes.at(-1),
466
+ locator: dependent.locator,
467
+ result,
468
+ type: "package-committed",
387
469
  });
470
+ } catch (error) {
471
+ // The dependent stays as it was; its Instances show in Repair.
472
+ followed.push({
473
+ error: { code: error?.code ?? "follow_variants_failed", message: error?.message },
474
+ instances: operations.length,
475
+ locator: dependent.locator,
476
+ });
477
+ }
478
+ }
479
+ return followed;
480
+ }
481
+
482
+ // Every open Package whose composite Workspace includes the changed one,
483
+ // directly or through a Foundation or Library chain.
484
+ #dependentSessions(changed) {
485
+ const dependents = [];
486
+ const seen = new Set([changed]);
487
+ const pending = [changed];
488
+ while (pending.length > 0) {
489
+ const packageId = pending.shift().backend.snapshot?.manifest.packageId;
490
+ if (!packageId) continue;
491
+ for (const session of this.#packages.values()) {
492
+ if (seen.has(session)) continue;
493
+ const manifest = session.backend.snapshot?.manifest;
494
+ if (
495
+ manifest?.dependencies?.some(
496
+ (dependency) => dependency.packageId === packageId,
497
+ ) ||
498
+ manifest?.libraries?.some((library) => library.packageId === packageId)
499
+ ) {
500
+ seen.add(session);
501
+ dependents.push(session);
502
+ pending.push(session);
503
+ }
388
504
  }
389
505
  }
506
+ return dependents;
390
507
  }
391
508
 
392
- async #updateWorkspaceStatus(session) {
509
+ #emitDependencyChanges(changed, event) {
510
+ for (const session of this.#dependentSessions(changed)) {
511
+ void this.#updateWorkspaceStatus(session).then(() => {
512
+ this.#emit({
513
+ dependencyEvent: event.type,
514
+ dependencyLocator: changed.locator,
515
+ locator: session.locator,
516
+ status: structuredClone(session.workspaceStatus),
517
+ type: "package-dependency-changed",
518
+ });
519
+ });
520
+ }
521
+ }
522
+
523
+ #scheduleLinkRepairRetry(session) {
524
+ clearTimeout(session.repairRetry);
525
+ session.repairRetry = undefined;
526
+ // A Package whose own state is invalid recovers through its Backend.
527
+ if (
528
+ session.workspaceStatus.state !== "repair" ||
529
+ session.backend.status.state !== "ready"
530
+ ) {
531
+ return;
532
+ }
533
+ session.repairRetry = setTimeout(async () => {
534
+ session.repairRetry = undefined;
535
+ if (this.#packages.get(session.locator) !== session) return;
536
+ const before = JSON.stringify(session.workspaceStatus);
537
+ await this.#openLinkedPackages(session.backend.snapshot);
538
+ if (this.#packages.get(session.locator) !== session) return;
539
+ await this.#updateWorkspaceStatus(session, { emitUnchanged: false });
540
+ if (JSON.stringify(session.workspaceStatus) === before) return;
541
+ this.#emit({
542
+ dependencyEvent: "link-repair-retry",
543
+ locator: session.locator,
544
+ status: structuredClone(session.workspaceStatus),
545
+ type: "package-dependency-changed",
546
+ });
547
+ }, LINK_REPAIR_RETRY_MS);
548
+ session.repairRetry.unref?.();
549
+ }
550
+
551
+ async #updateWorkspaceStatus(
552
+ session,
553
+ { emitUnchanged = true, reuseRemoteLibraries = true } = {},
554
+ ) {
555
+ // Updates run concurrently (events do not await them); only the latest
556
+ // one started may set the status, so a slow older read never wins.
557
+ session.statusSequence += 1;
558
+ const sequence = session.statusSequence;
559
+ const before = JSON.stringify(session.workspaceStatus);
560
+ let resolution;
561
+ let failure;
393
562
  try {
394
- const resolution = await resolveWorkspace(session.locator, {
563
+ resolution = await resolveWorkspace(session.locator, {
564
+ fetchImpl: this.#fetchImpl,
395
565
  libraryCacheRoot: this.#libraryCacheRoot,
566
+ remoteLibrarySnapshots: this.#remoteLibraries,
567
+ reuseRemoteLibrarySnapshots: reuseRemoteLibraries,
396
568
  });
569
+ } catch (error) {
570
+ failure = error;
571
+ }
572
+ if (sequence !== session.statusSequence) return session.workspaceStatus;
573
+ if (resolution) {
397
574
  if (resolution.status === "ready") {
398
575
  session.lastValidWorkspace = resolution.workspace;
399
576
  session.repairWorkspace = undefined;
@@ -429,7 +606,8 @@ export class LocalWorkspaceSession {
429
606
  state: "repair",
430
607
  };
431
608
  }
432
- } catch (error) {
609
+ } else {
610
+ const error = failure;
433
611
  session.workspaceStatus = {
434
612
  error: {
435
613
  code: error?.code ?? "invalid_workspace_state",
@@ -444,11 +622,14 @@ export class LocalWorkspaceSession {
444
622
  state: "repair",
445
623
  };
446
624
  }
447
- this.#emit({
448
- locator: session.locator,
449
- status: structuredClone(session.workspaceStatus),
450
- type: "package-workspace-status",
451
- });
625
+ this.#scheduleLinkRepairRetry(session);
626
+ if (emitUnchanged || JSON.stringify(session.workspaceStatus) !== before) {
627
+ this.#emit({
628
+ locator: session.locator,
629
+ status: structuredClone(session.workspaceStatus),
630
+ type: "package-workspace-status",
631
+ });
632
+ }
452
633
  return session.workspaceStatus;
453
634
  }
454
635
 
@@ -467,6 +648,7 @@ export class LocalWorkspaceSession {
467
648
  type,
468
649
  value,
469
650
  });
651
+ this.#emitDependencyChanges(session, { type });
470
652
  return value;
471
653
  }
472
654
 
@@ -0,0 +1,116 @@
1
+ // `smallpen migrate-themes`: ADR 0003 upgrade by copy. The original Package
2
+ // (or Foundation + Product pair) stays untouched; the converted copy is
3
+ // written beside it in one rename.
4
+ import { cp, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+
7
+ import {
8
+ canonicalJSON,
9
+ migrateTokenThemes,
10
+ SmallPenError,
11
+ } from "@smallpen/core";
12
+
13
+ import { pathExists } from "./fs-utils.mjs";
14
+ import { openWorkspace } from "./workspace.mjs";
15
+
16
+ async function writePackage(packagePath, { deletedEntries, entries, manifest }) {
17
+ await writeFile(join(packagePath, "manifest.json"), canonicalJSON(manifest), "utf8");
18
+ for (const [entry, value] of Object.entries(entries)) {
19
+ const output = join(packagePath, entry);
20
+ await mkdir(dirname(output), { recursive: true });
21
+ await writeFile(output, canonicalJSON(value), "utf8");
22
+ }
23
+ for (const entry of deletedEntries) {
24
+ if (Object.hasOwn(entries, entry)) continue;
25
+ await rm(join(packagePath, entry), { force: true });
26
+ }
27
+ }
28
+
29
+ export async function migrateThemesByCopy(locator, outputLocator) {
30
+ if (typeof outputLocator !== "string" || outputLocator.length === 0) {
31
+ throw new SmallPenError(
32
+ "missing_migration_output",
33
+ "migrate-themes requires --output NEW_PATH; the original is never changed",
34
+ );
35
+ }
36
+ const workspace = await openWorkspace(locator);
37
+ const { foundation, product } = workspace;
38
+ const pair = Boolean(foundation) && foundation !== product;
39
+ const outputPath = resolve(outputLocator);
40
+ if (!pair && !outputPath.toLowerCase().endsWith(".smallpen")) {
41
+ throw new SmallPenError(
42
+ "invalid_package_path",
43
+ "--output must be a new <name>.smallpen path for a single Package",
44
+ { outputPath },
45
+ );
46
+ }
47
+ if (await pathExists(outputPath)) {
48
+ throw new SmallPenError(
49
+ "migration_output_exists",
50
+ `Migration output already exists: ${outputPath}`,
51
+ { outputPath },
52
+ );
53
+ }
54
+ const result = migrateTokenThemes({ foundation, product });
55
+ const parent = dirname(outputPath);
56
+ await mkdir(parent, { recursive: true });
57
+ const transactionPath = await mkdtemp(join(parent, ".smallpen-migrate-"));
58
+ const candidatePath = join(transactionPath, basename(outputPath));
59
+ const dependency = pair
60
+ ? product.manifest.dependencies.find(
61
+ ({ packageId }) => packageId === foundation.manifest.packageId,
62
+ )
63
+ : undefined;
64
+ // Package directory of each migrated Package, relative to candidatePath.
65
+ const targets = new Map(
66
+ pair
67
+ ? [
68
+ [foundation.manifest.packageId, dependency.path],
69
+ [product.manifest.packageId, basename(product.locator)],
70
+ ]
71
+ : [[product.manifest.packageId, "."]],
72
+ );
73
+ const sources = new Map(
74
+ [foundation, product].filter(Boolean).map((snapshot) => [
75
+ snapshot.manifest.packageId,
76
+ snapshot.locator,
77
+ ]),
78
+ );
79
+ try {
80
+ for (const migrated of result.packages) {
81
+ const target = join(candidatePath, targets.get(migrated.packageId));
82
+ // Copy everything first so blobs and other files travel along.
83
+ await cp(sources.get(migrated.packageId), target, { recursive: true });
84
+ await writePackage(target, migrated);
85
+ }
86
+ const productTarget = join(candidatePath, targets.get(product.manifest.packageId));
87
+ const opened = await openWorkspace(productTarget);
88
+ await rename(candidatePath, outputPath);
89
+ await rm(transactionPath, { force: true, recursive: true });
90
+ const finalPath = (packageId) => join(outputPath, targets.get(packageId));
91
+ return {
92
+ original: pair
93
+ ? { foundationPath: foundation.locator, productPath: product.locator }
94
+ : { packagePath: product.locator },
95
+ output: pair
96
+ ? {
97
+ foundationPath: finalPath(foundation.manifest.packageId),
98
+ productPath: finalPath(product.manifest.packageId),
99
+ workspacePath: outputPath,
100
+ }
101
+ : { packagePath: outputPath },
102
+ packages: [opened.foundation, opened.product]
103
+ .filter(Boolean)
104
+ .map((snapshot) => ({
105
+ packageId: snapshot.manifest.packageId,
106
+ revision: snapshot.revision,
107
+ })),
108
+ status: "migrated",
109
+ themes: result.themes,
110
+ warnings: result.warnings,
111
+ };
112
+ } catch (error) {
113
+ await rm(transactionPath, { force: true, recursive: true });
114
+ throw error;
115
+ }
116
+ }