@ebay-dt/bonfire-node 0.2.0-alpha.10 → 0.2.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 (45) hide show
  1. package/README.md +18 -5
  2. package/dist/deployment-provider.d.ts +1 -1
  3. package/dist/deployment-provider.d.ts.map +1 -1
  4. package/dist/deployment-provider.js +1 -1
  5. package/dist/deployment-provider.js.map +1 -1
  6. package/dist/duplicate.d.ts.map +1 -1
  7. package/dist/duplicate.js +103 -47
  8. package/dist/duplicate.js.map +1 -1
  9. package/dist/git-repository.d.ts +14 -1
  10. package/dist/git-repository.d.ts.map +1 -1
  11. package/dist/git-repository.js +99 -3
  12. package/dist/git-repository.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/prototype-templates.d.ts +0 -1
  18. package/dist/internal/prototype-templates.d.ts.map +1 -1
  19. package/dist/internal/prototype-templates.js +0 -5
  20. package/dist/internal/prototype-templates.js.map +1 -1
  21. package/dist/lifecycle/deployment-worktree.d.ts +5 -4
  22. package/dist/lifecycle/deployment-worktree.d.ts.map +1 -1
  23. package/dist/lifecycle/deployment-worktree.js +269 -68
  24. package/dist/lifecycle/deployment-worktree.js.map +1 -1
  25. package/dist/lifecycle/local-prototype-lifecycle.d.ts +13 -1
  26. package/dist/lifecycle/local-prototype-lifecycle.d.ts.map +1 -1
  27. package/dist/lifecycle/local-prototype-lifecycle.js +634 -110
  28. package/dist/lifecycle/local-prototype-lifecycle.js.map +1 -1
  29. package/dist/lifecycle/preparation-marker.d.ts +12 -0
  30. package/dist/lifecycle/preparation-marker.d.ts.map +1 -0
  31. package/dist/lifecycle/preparation-marker.js +76 -0
  32. package/dist/lifecycle/preparation-marker.js.map +1 -0
  33. package/dist/lifecycle/prototype-path-policy.d.ts +18 -0
  34. package/dist/lifecycle/prototype-path-policy.d.ts.map +1 -0
  35. package/dist/lifecycle/prototype-path-policy.js +429 -0
  36. package/dist/lifecycle/prototype-path-policy.js.map +1 -0
  37. package/dist/lifecycle/repository-summary.d.ts +5 -1
  38. package/dist/lifecycle/repository-summary.d.ts.map +1 -1
  39. package/dist/lifecycle/repository-summary.js +6 -0
  40. package/dist/lifecycle/repository-summary.js.map +1 -1
  41. package/dist/lifecycle/repository-synchronization.d.ts +13 -9
  42. package/dist/lifecycle/repository-synchronization.d.ts.map +1 -1
  43. package/dist/lifecycle/repository-synchronization.js +670 -173
  44. package/dist/lifecycle/repository-synchronization.js.map +1 -1
  45. package/package.json +2 -2
@@ -1,9 +1,11 @@
1
1
  import { constants } from "node:fs";
2
+ import { execFile as execFileCallback } from "node:child_process";
2
3
  import { lstat, open, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { randomUUID } from "node:crypto";
5
+ import { promisify } from "node:util";
4
6
  import { join, relative, sep } from "node:path";
5
7
  import { definePrototypeRef, getPrototypeUrl, LifecycleInputError, lifecycleFailure, lifecycleSuccess, parseCreatePrototypeInput, parseDuplicatePrototypeInput, parseEditPrototypeInput, parseBonfireIdentity, parsePrototypeCommandInput, parsePrototypeMetadata, slugifyPrototypeTitle, PrototypeRefError, parseGitModeSetting, } from "@ebay-dt/bonfire-core";
6
- import { commitPath, discardPath, GitRepositoryError, pushBranch, pushCommitToBranch, } from "../git-repository.js";
8
+ import { commitPath, GitRepositoryError, pushBranch, pushCommitToBranch, remoteBranchCommit, } from "../git-repository.js";
7
9
  import { duplicatePrototype, PrototypeDuplicateError } from "../duplicate.js";
8
10
  import { PrototypeCatalog, } from "../prototype-catalog.js";
9
11
  import { PrototypeScaffoldError, scaffoldPrototype } from "../scaffold.js";
@@ -13,11 +15,19 @@ import { RepositoryBusyError, withRepositoryOperation, } from "./operation-gate.
13
15
  import { DeploymentProviderError, } from "../deployment-provider.js";
14
16
  import { assertIdentityBranchReady, synchronizeIdentityBranch, } from "./repository-synchronization.js";
15
17
  import { getRepositorySummary } from "./repository-summary.js";
16
- import { DeploymentWorktreeError, preflightDeploymentSource, publishDeploymentSource, reportDeploymentIssue, } from "./deployment-worktree.js";
18
+ import { DeploymentWorktreeError, publishDeploymentSource, reportDeploymentIssue, } from "./deployment-worktree.js";
19
+ import { commitPrototypePath, deletePrototypePath, discardPrototypePath, } from "./prototype-path-policy.js";
20
+ import { clearPreparationMarker, readPreparationMarker, writePreparationMarker, } from "./preparation-marker.js";
21
+ const execFile = promisify(execFileCallback);
17
22
  function repositoryRelativePath(runtime, ref) {
18
23
  const absolutePath = join(runtime.prototypeRoot, ref.login, ref.slug);
19
24
  return relative(runtime.repositoryRoot, absolutePath).split(sep).join("/");
20
25
  }
26
+ function namespaceRelativePath(runtime, login) {
27
+ return relative(runtime.repositoryRoot, join(runtime.prototypeRoot, login))
28
+ .split(sep)
29
+ .join("/");
30
+ }
21
31
  function destinationRef(identity, title) {
22
32
  const slug = slugifyPrototypeTitle(title);
23
33
  if (slug === null)
@@ -36,21 +46,68 @@ class DeploymentSourcePushError extends Error {
36
46
  }
37
47
  class DeploymentNotAcceptedError extends Error {
38
48
  }
49
+ const pendingPublications = new Map();
50
+ function pendingPublicationKey(repositoryRoot, login) {
51
+ return `${repositoryRoot}\0${login}`;
52
+ }
39
53
  function metadataPath(runtime, ref) {
40
54
  return join(runtime.prototypeRoot, ref.login, ref.slug, "metadata.json");
41
55
  }
42
- async function assertPrototypeDirectory(runtime, ref) {
43
- const paths = [
56
+ function prototypeDirectoryPaths(runtime, ref) {
57
+ return [
44
58
  runtime.prototypeRoot,
45
59
  join(runtime.prototypeRoot, ref.login),
46
60
  join(runtime.prototypeRoot, ref.login, ref.slug),
47
61
  ];
48
- try {
49
- for (const path of paths) {
62
+ }
63
+ async function capturePrototypeDirectory(runtime, ref) {
64
+ const entries = [];
65
+ for (const path of prototypeDirectoryPaths(runtime, ref)) {
66
+ try {
50
67
  const stats = await lstat(path);
51
- if (stats.isSymbolicLink() || !stats.isDirectory()) {
52
- throw new PrototypeMetadataFileError();
68
+ entries.push({
69
+ path,
70
+ kind: stats.isSymbolicLink()
71
+ ? "symlink"
72
+ : stats.isDirectory()
73
+ ? "directory"
74
+ : "other",
75
+ dev: stats.dev,
76
+ ino: stats.ino,
77
+ });
78
+ }
79
+ catch (error) {
80
+ if (error &&
81
+ typeof error === "object" &&
82
+ "code" in error &&
83
+ error.code === "ENOENT") {
84
+ entries.push({ path, kind: "missing" });
85
+ continue;
53
86
  }
87
+ throw new PrototypeMetadataFileError();
88
+ }
89
+ }
90
+ return entries;
91
+ }
92
+ function samePrototypeDirectorySnapshot(expected, actual) {
93
+ return expected.every((entry, index) => {
94
+ const current = actual[index];
95
+ return (current !== undefined &&
96
+ current.path === entry.path &&
97
+ current.kind === entry.kind &&
98
+ current.dev === entry.dev &&
99
+ current.ino === entry.ino);
100
+ });
101
+ }
102
+ async function assertPrototypeDirectory(runtime, ref, expected) {
103
+ try {
104
+ const actual = await capturePrototypeDirectory(runtime, ref);
105
+ if (expected !== undefined &&
106
+ !samePrototypeDirectorySnapshot(expected, actual)) {
107
+ throw new PrototypeMetadataFileError();
108
+ }
109
+ if (actual.some((entry) => entry.kind !== "directory")) {
110
+ throw new PrototypeMetadataFileError();
54
111
  }
55
112
  }
56
113
  catch (error) {
@@ -84,18 +141,6 @@ async function readMetadataDocument(runtime, ref, themeRegistry) {
84
141
  throw new PrototypeMetadataFileError();
85
142
  }
86
143
  }
87
- async function removePrototypeDirectory(runtime, ref) {
88
- await assertPrototypeDirectory(runtime, ref);
89
- try {
90
- await rm(join(runtime.prototypeRoot, ref.login, ref.slug), {
91
- force: false,
92
- recursive: true,
93
- });
94
- }
95
- catch {
96
- throw new PrototypeMetadataFileError();
97
- }
98
- }
99
144
  async function writeMetadataDocument(runtime, ref, document) {
100
145
  const path = metadataPath(runtime, ref);
101
146
  const temporaryPath = `${path}.${randomUUID()}.tmp`;
@@ -193,8 +238,10 @@ export class LocalPrototypeLifecycle {
193
238
  #deploymentProvider;
194
239
  #applicationId;
195
240
  #themeRegistry;
241
+ #publishDeploymentSource;
242
+ #installDependencies;
196
243
  #restartRequired = false;
197
- #pendingPublication;
244
+ #preparationAttention = new Map();
198
245
  constructor(options) {
199
246
  const internalOptions = options;
200
247
  const runtime = internalOptions.runtime ??
@@ -216,6 +263,11 @@ export class LocalPrototypeLifecycle {
216
263
  internalOptions.preparePrototypeFiles ?? (() => undefined);
217
264
  this.#deploymentProvider = internalOptions.deploymentProvider;
218
265
  this.#applicationId = internalOptions.applicationId;
266
+ this.#publishDeploymentSource =
267
+ internalOptions.publishDeploymentSource === null
268
+ ? null
269
+ : (internalOptions.publishDeploymentSource ?? publishDeploymentSource);
270
+ this.#installDependencies = options.installDependencies;
219
271
  if (this.#deploymentProvider &&
220
272
  (typeof this.#applicationId !== "string" ||
221
273
  this.#applicationId.length === 0)) {
@@ -232,22 +284,27 @@ export class LocalPrototypeLifecycle {
232
284
  }
233
285
  return branch;
234
286
  }
235
- async #ownedPrototype(identity, input) {
287
+ async #authorizedPrototype(identity, input) {
236
288
  const verifiedIdentity = await this.#verifiedIdentity(identity);
237
289
  const normalizedInput = parsePrototypeCommandInput(input);
238
290
  if (normalizedInput.ref.login !== verifiedIdentity.login) {
239
291
  throw new PrototypeOwnershipError();
240
292
  }
241
- const summary = await this.#catalog.getSummary(normalizedInput.ref);
242
- if (!summary) {
243
- throw new PrototypeNotFoundError();
244
- }
293
+ const directorySnapshot = await capturePrototypeDirectory(this.#runtime, normalizedInput.ref);
245
294
  return {
246
295
  ref: normalizedInput.ref,
247
296
  path: repositoryRelativePath(this.#runtime, normalizedInput.ref),
248
- summary,
297
+ directorySnapshot,
249
298
  };
250
299
  }
300
+ async #ownedPrototype(identity, input) {
301
+ const authorized = await this.#authorizedPrototype(identity, input);
302
+ const summary = await this.#catalog.getSummary(authorized.ref);
303
+ if (!summary) {
304
+ throw new PrototypeNotFoundError();
305
+ }
306
+ return { ...authorized, summary };
307
+ }
251
308
  async #prepareMutationBranch(login) {
252
309
  await assertIdentityBranchReady(this.#repository, login);
253
310
  return login;
@@ -282,35 +339,160 @@ export class LocalPrototypeLifecycle {
282
339
  if (this.#runtime.gitMode === "manual" ||
283
340
  this.#runtime.automaticBranchSwitch === "disabled") {
284
341
  await this.#currentBranch();
285
- return { status: "disabled" };
342
+ const disabled = {
343
+ status: "disabled",
344
+ effects: {
345
+ changed: false,
346
+ pushed: false,
347
+ reconciledPaths: [],
348
+ restartRequired: false,
349
+ },
350
+ };
351
+ this.#preparationAttention.set(verifiedIdentity.login, disabled);
352
+ return disabled;
286
353
  }
287
354
  const synchronization = await synchronizeIdentityBranch(this.#repository, {
288
355
  identityBranch: verifiedIdentity.login,
289
356
  primaryBranch: this.#runtime.primaryBranch,
290
357
  remote: this.#runtime.remote,
291
- managedPaths: this.#runtime.managedRepositoryPaths,
358
+ prototypeRoot: relative(this.#runtime.repositoryRoot, this.#runtime.prototypeRoot)
359
+ .split(sep)
360
+ .join("/"),
292
361
  });
293
- if (synchronization.status !== "synchronized") {
294
- return {
295
- status: synchronization.status,
362
+ const reconciledPaths = synchronization.status === "synchronized" &&
363
+ synchronization.platformPaths.length === 0
364
+ ? synchronization.affectedPaths.filter((path) => this.#runtime.managedRepositoryPaths.includes(path))
365
+ : synchronization.platformPaths;
366
+ const effects = {
367
+ changed: synchronization.changed,
368
+ pushed: synchronization.pushed,
369
+ reconciledPaths,
370
+ restartRequired: synchronization.status === "synchronized" &&
371
+ synchronization.platformPaths.length === 0
372
+ ? reconciledPaths.length > 0
373
+ : synchronization.restartRequired,
374
+ ...(synchronization.platformPaths.length > 0
375
+ ? { platformPaths: synchronization.platformPaths }
376
+ : {}),
377
+ ...(synchronization.dependencyPaths.length > 0
378
+ ? { dependencyPaths: synchronization.dependencyPaths }
379
+ : {}),
380
+ ...(synchronization.preparationRequired
381
+ ? { preparationRequired: true }
382
+ : {}),
383
+ };
384
+ if (effects.restartRequired)
385
+ this.#restartRequired = true;
386
+ if (effects.preparationRequired) {
387
+ await writePreparationMarker(this.#repository, this.#runtime.repositoryRoot, effects.dependencyPaths ?? []);
388
+ }
389
+ if (synchronization.status === "deferred") {
390
+ const attention = {
391
+ status: "deferred",
296
392
  reason: synchronization.reason,
393
+ effects,
297
394
  ...(synchronization.affectedPaths.length > 0
298
395
  ? { affectedPaths: synchronization.affectedPaths }
299
396
  : {}),
300
397
  };
398
+ this.#preparationAttention.set(verifiedIdentity.login, attention);
399
+ return attention;
301
400
  }
302
- if (synchronization.affectedPaths.some((path) => this.#runtime.managedRepositoryPaths.includes(path))) {
303
- this.#restartRequired = true;
401
+ if (synchronization.status === "unsafe") {
402
+ const attention = {
403
+ status: "unsafe",
404
+ reason: synchronization.reason,
405
+ effects,
406
+ ...(synchronization.affectedPaths.length > 0
407
+ ? { affectedPaths: synchronization.affectedPaths }
408
+ : {}),
409
+ };
410
+ this.#preparationAttention.set(verifiedIdentity.login, attention);
411
+ return attention;
304
412
  }
305
- return {
413
+ const ready = {
306
414
  status: "ready",
415
+ effects,
307
416
  ...(synchronization.affectedPaths.length > 0
308
417
  ? { affectedPaths: synchronization.affectedPaths }
309
418
  : {}),
310
419
  };
420
+ this.#preparationAttention.delete(verifiedIdentity.login);
421
+ return ready;
311
422
  });
312
423
  });
313
424
  }
425
+ async prepareDependencies(dependencyPaths = [], installer) {
426
+ return resultFrom(async () => withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
427
+ let pending = null;
428
+ try {
429
+ pending = await readPreparationMarker(this.#repository, this.#runtime.repositoryRoot);
430
+ }
431
+ catch {
432
+ // A retained/corrupt marker is recovery truth. Restricted modes must
433
+ // preserve it without attempting installation; managed mode must
434
+ // report not-ready rather than guessing what to install.
435
+ if (this.#runtime.gitMode === "manual" ||
436
+ this.#runtime.automaticBranchSwitch === "disabled") {
437
+ return {
438
+ status: "skipped",
439
+ dependencyPaths: [...dependencyPaths],
440
+ pending: true,
441
+ restartRequired: false,
442
+ };
443
+ }
444
+ return {
445
+ status: "failed",
446
+ dependencyPaths: [...dependencyPaths],
447
+ pending: true,
448
+ restartRequired: true,
449
+ };
450
+ }
451
+ const paths = [...new Set([...(pending ?? []), ...dependencyPaths])];
452
+ if (this.#runtime.gitMode === "manual" ||
453
+ this.#runtime.automaticBranchSwitch === "disabled") {
454
+ return {
455
+ status: "skipped",
456
+ dependencyPaths: paths,
457
+ pending: pending !== null,
458
+ restartRequired: false,
459
+ };
460
+ }
461
+ // An empty path list is still an obligation when the marker exists.
462
+ // Do not reinterpret a retained marker as "not required".
463
+ if (paths.length === 0 && pending === null) {
464
+ return {
465
+ status: "not-required",
466
+ dependencyPaths: [],
467
+ pending: false,
468
+ restartRequired: false,
469
+ };
470
+ }
471
+ await writePreparationMarker(this.#repository, this.#runtime.repositoryRoot, paths);
472
+ try {
473
+ await (installer ??
474
+ this.#installDependencies ??
475
+ (async (root) => {
476
+ await execFile("pnpm", ["install"], { cwd: root });
477
+ }))(this.#runtime.repositoryRoot);
478
+ }
479
+ catch {
480
+ return {
481
+ status: "failed",
482
+ dependencyPaths: paths,
483
+ pending: true,
484
+ restartRequired: true,
485
+ };
486
+ }
487
+ await clearPreparationMarker(this.#repository, this.#runtime.repositoryRoot);
488
+ return {
489
+ status: "prepared",
490
+ dependencyPaths: paths,
491
+ pending: false,
492
+ restartRequired: true,
493
+ };
494
+ }));
495
+ }
314
496
  async create(identity, input) {
315
497
  return resultFrom(async () => {
316
498
  const verifiedIdentity = await this.#verifiedIdentity(identity);
@@ -350,11 +532,13 @@ export class LocalPrototypeLifecycle {
350
532
  };
351
533
  }
352
534
  await commitPath(this.#repository, repositoryRelativePath(this.#runtime, ref), `init: ${ref.slug}`);
353
- const push = await pushBranch(this.#repository, this.#runtime.remote, branch);
535
+ const sourceSha = await this.#headCommit();
536
+ const outcome = await this.#publishOrdinaryNamespace(verifiedIdentity, "create", ref, repositoryRelativePath(this.#runtime, ref), sourceSha);
354
537
  return {
355
538
  ref,
356
539
  url: getPrototypeUrl(ref),
357
- remoteStatus: push.remoteStatus,
540
+ remoteStatus: "local-only",
541
+ outcome,
358
542
  };
359
543
  });
360
544
  });
@@ -400,16 +584,34 @@ export class LocalPrototypeLifecycle {
400
584
  const verifiedIdentity = await this.#verifiedIdentity(identity);
401
585
  const normalizedInput = parsePrototypeCommandInput(input);
402
586
  return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
403
- const { ref, path } = await this.#ownedPrototype(verifiedIdentity, normalizedInput);
587
+ const { ref, path, directorySnapshot } = await this.#authorizedPrototype(verifiedIdentity, normalizedInput);
588
+ if (directorySnapshot.at(-1)?.kind === "missing") {
589
+ throw new PrototypeNotFoundError();
590
+ }
404
591
  const branch = this.#runtime.gitMode === "managed"
405
592
  ? await this.#prepareMutationBranch(verifiedIdentity.login)
406
593
  : undefined;
407
- await removePrototypeDirectory(this.#runtime, ref);
594
+ await assertPrototypeDirectory(this.#runtime, ref, directorySnapshot);
595
+ await deletePrototypePath(this.#repository, path, branch === undefined ? undefined : `delete: ${ref.slug}`);
408
596
  if (branch !== undefined) {
409
- await commitPath(this.#repository, path, `delete: ${ref.slug}`);
410
- await pushBranch(this.#repository, this.#runtime.remote, branch);
597
+ const sourceSha = await this.#headCommit();
598
+ const outcome = await this.#publishOrdinaryNamespace(verifiedIdentity, "delete", ref, path, sourceSha);
599
+ return {
600
+ deleted: true,
601
+ ref,
602
+ outcome,
603
+ };
411
604
  }
412
- return { deleted: true, ref };
605
+ return {
606
+ deleted: true,
607
+ ref,
608
+ outcome: {
609
+ local: { status: "completed" },
610
+ upload: { status: "not-attempted" },
611
+ preview: { status: "not-attempted" },
612
+ publication: { status: "not-attempted" },
613
+ },
614
+ };
413
615
  });
414
616
  });
415
617
  }
@@ -428,7 +630,7 @@ export class LocalPrototypeLifecycle {
428
630
  async getRepositorySummary(identity) {
429
631
  return resultFrom(async () => {
430
632
  const verifiedIdentity = await this.#verifiedIdentity(identity);
431
- return getRepositorySummary({
633
+ return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => getRepositorySummary({
432
634
  repository: this.#repository,
433
635
  catalog: this.#catalog,
434
636
  repositoryRoot: this.#runtime.repositoryRoot,
@@ -438,13 +640,15 @@ export class LocalPrototypeLifecycle {
438
640
  remote: this.#runtime.remote,
439
641
  primaryBranch: this.#runtime.primaryBranch,
440
642
  restartRequired: this.#restartRequired,
441
- });
643
+ preparationAttention: this.#preparationAttention.get(verifiedIdentity.login),
644
+ preparationPending: (await readPreparationMarker(this.#repository, this.#runtime.repositoryRoot)) !== null,
645
+ }));
442
646
  });
443
647
  }
444
648
  async checkRepositorySummary(identity) {
445
649
  return resultFrom(async () => {
446
650
  const verifiedIdentity = await this.#verifiedIdentity(identity);
447
- return getRepositorySummary({
651
+ return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => getRepositorySummary({
448
652
  repository: this.#repository,
449
653
  catalog: this.#catalog,
450
654
  repositoryRoot: this.#runtime.repositoryRoot,
@@ -454,8 +658,10 @@ export class LocalPrototypeLifecycle {
454
658
  remote: this.#runtime.remote,
455
659
  primaryBranch: this.#runtime.primaryBranch,
456
660
  restartRequired: this.#restartRequired,
661
+ preparationAttention: this.#preparationAttention.get(verifiedIdentity.login),
662
+ preparationPending: (await readPreparationMarker(this.#repository, this.#runtime.repositoryRoot)) !== null,
457
663
  refresh: true,
458
- });
664
+ }));
459
665
  });
460
666
  }
461
667
  async #headCommit() {
@@ -465,25 +671,6 @@ export class LocalPrototypeLifecycle {
465
671
  }
466
672
  return commit.toLowerCase();
467
673
  }
468
- async #prepareDeploymentBranch(login) {
469
- if (this.#runtime.automaticBranchSwitch === "disabled") {
470
- await assertIdentityBranchReady(this.#repository, login);
471
- return "updates-not-synchronized";
472
- }
473
- const synchronization = await synchronizeIdentityBranch(this.#repository, {
474
- identityBranch: login,
475
- primaryBranch: this.#runtime.primaryBranch,
476
- remote: this.#runtime.remote,
477
- managedPaths: this.#runtime.managedRepositoryPaths,
478
- });
479
- if (synchronization.status === "unsafe") {
480
- throw new GitRepositoryError("conflict", "deployment-preparation");
481
- }
482
- await assertIdentityBranchReady(this.#repository, login);
483
- return synchronization.status === "deferred"
484
- ? "updates-not-synchronized"
485
- : undefined;
486
- }
487
674
  #workspaceRuntime(workspacePath, workspaceRepository) {
488
675
  return {
489
676
  ...this.#runtime,
@@ -493,11 +680,100 @@ export class LocalPrototypeLifecycle {
493
680
  gitRepository: workspaceRepository,
494
681
  };
495
682
  }
683
+ #pendingKey(login) {
684
+ return pendingPublicationKey(this.#runtime.repositoryRoot, login);
685
+ }
686
+ #rememberPending(pending) {
687
+ pendingPublications.set(this.#pendingKey(pending.identity.login), pending);
688
+ }
689
+ #clearPending(login) {
690
+ pendingPublications.delete(this.#pendingKey(login));
691
+ }
692
+ #recoveryValue(pending, outcome) {
693
+ return {
694
+ recoveryId: pending.recoveryId,
695
+ operation: pending.operation,
696
+ kind: pending.kind,
697
+ ref: pending.ref,
698
+ sourceSha: pending.sourceSha,
699
+ ...(pending.previewUrl === undefined
700
+ ? {}
701
+ : { previewUrl: pending.previewUrl }),
702
+ ...(pending.deploymentId === undefined
703
+ ? {}
704
+ : { deploymentId: pending.deploymentId }),
705
+ ...(pending.readyState === undefined
706
+ ? {}
707
+ : { readyState: pending.readyState }),
708
+ ...(pending.acceptedAt === undefined
709
+ ? {}
710
+ : { acceptedAt: pending.acceptedAt }),
711
+ outcome,
712
+ };
713
+ }
714
+ async #publishCommittedNamespace(identity, sourceSha) {
715
+ if (this.#publishDeploymentSource === null) {
716
+ throw new DeploymentUnavailableError();
717
+ }
718
+ return this.#publishDeploymentSource(this.#repository, {
719
+ remote: this.#runtime.remote,
720
+ primaryBranch: this.#runtime.primaryBranch,
721
+ sourceSha,
722
+ namespacePath: namespaceRelativePath(this.#runtime, identity.login),
723
+ commitIdentity: this.#runtime.commitIdentity,
724
+ }, async () => undefined);
725
+ }
726
+ async #publishOrdinaryNamespace(identity, operation, ref, path, sourceSha) {
727
+ const baseOutcome = {
728
+ local: { status: "completed", checkpointSha: sourceSha },
729
+ upload: { status: "not-attempted", sourceSha },
730
+ preview: { status: "not-attempted" },
731
+ publication: {
732
+ status: this.#publishDeploymentSource === null ? "not-attempted" : "pending",
733
+ },
734
+ };
735
+ if (this.#publishDeploymentSource === null)
736
+ return baseOutcome;
737
+ try {
738
+ const publication = await this.#publishCommittedNamespace(identity, sourceSha);
739
+ this.#clearPending(identity.login);
740
+ return {
741
+ ...baseOutcome,
742
+ publication: {
743
+ status: "confirmed",
744
+ revision: publication.publicationSha,
745
+ },
746
+ };
747
+ }
748
+ catch {
749
+ const pending = {
750
+ recoveryId: randomUUID(),
751
+ operation,
752
+ kind: "ordinary-publication",
753
+ identity,
754
+ ref,
755
+ path,
756
+ sourceSha,
757
+ };
758
+ this.#rememberPending(pending);
759
+ return {
760
+ ...baseOutcome,
761
+ publication: {
762
+ status: "failed",
763
+ recoveryId: pending.recoveryId,
764
+ },
765
+ };
766
+ }
767
+ }
496
768
  async #publishDeploymentMetadata(pending) {
497
- return publishDeploymentSource(this.#repository, {
769
+ if (this.#publishDeploymentSource === null) {
770
+ throw new DeploymentUnavailableError();
771
+ }
772
+ return this.#publishDeploymentSource(this.#repository, {
498
773
  remote: this.#runtime.remote,
499
774
  primaryBranch: this.#runtime.primaryBranch,
500
775
  sourceSha: pending.sourceSha,
776
+ namespacePath: namespaceRelativePath(this.#runtime, pending.identity.login),
501
777
  commitIdentity: this.#runtime.commitIdentity,
502
778
  }, async ({ path, repository }) => {
503
779
  const workspaceRuntime = this.#workspaceRuntime(path, repository);
@@ -505,18 +781,13 @@ export class LocalPrototypeLifecycle {
505
781
  await diagnoseMetadataStep("metadata-write", () => writeMetadataDocument(workspaceRuntime, pending.ref, {
506
782
  ...mergedDocument,
507
783
  previewUrl: pending.previewUrl,
508
- updatedAt: new Date().toISOString(),
784
+ updatedAt: pending.acceptedAt,
509
785
  }));
510
786
  await diagnoseMetadataStep("metadata-commit", () => commitPath(repository, pending.path, `deploy: ${pending.ref.slug} (metadata)`));
511
787
  });
512
788
  }
513
- async #deploymentValue(pending, publicationWarnings = []) {
514
- const warnings = [
515
- ...(pending.synchronizationWarning === undefined
516
- ? []
517
- : [pending.synchronizationWarning]),
518
- ...publicationWarnings,
519
- ];
789
+ async #deploymentValue(pending, publicationWarnings = [], outcome) {
790
+ const warnings = [...publicationWarnings];
520
791
  return {
521
792
  deployed: true,
522
793
  ref: pending.ref,
@@ -524,7 +795,8 @@ export class LocalPrototypeLifecycle {
524
795
  deploymentId: pending.deploymentId,
525
796
  sourceSha: pending.sourceSha,
526
797
  readyState: pending.readyState,
527
- warning: publicationWarnings[0] ?? pending.synchronizationWarning,
798
+ ...(outcome === undefined ? {} : { outcome }),
799
+ warning: publicationWarnings[0],
528
800
  ...(warnings.length === 0 ? {} : { warnings }),
529
801
  };
530
802
  }
@@ -540,7 +812,7 @@ export class LocalPrototypeLifecycle {
540
812
  }
541
813
  return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
542
814
  const { ref, path } = await this.#ownedPrototype(verifiedIdentity, normalizedInput);
543
- const synchronizationWarning = await this.#prepareDeploymentBranch(verifiedIdentity.login);
815
+ await assertIdentityBranchReady(this.#repository, verifiedIdentity.login);
544
816
  await assertPrototypeDirectory(this.#runtime, ref);
545
817
  const document = await readMetadataDocument(this.#runtime, ref, this.#themeRegistry);
546
818
  const metadata = parsePrototypeMetadata(document, {
@@ -549,94 +821,344 @@ export class LocalPrototypeLifecycle {
549
821
  if (metadata.authorLogin !== ref.login) {
550
822
  throw new PrototypeNotFoundError();
551
823
  }
552
- await commitPath(this.#repository, path, `deploy: ${ref.slug}`);
824
+ await commitPrototypePath(this.#repository, path, `deploy: ${ref.slug}`);
553
825
  const sourceSha = await this.#headCommit();
554
- const sourcePush = await pushCommitToBranch(this.#repository, this.#runtime.remote, sourceSha, verifiedIdentity.login);
826
+ let sourcePush;
827
+ try {
828
+ sourcePush = await pushCommitToBranch(this.#repository, this.#runtime.remote, sourceSha, verifiedIdentity.login, true);
829
+ }
830
+ catch (error) {
831
+ if (error instanceof GitRepositoryError) {
832
+ const pending = {
833
+ recoveryId: randomUUID(),
834
+ operation: "deploy",
835
+ kind: "deployment-upload",
836
+ identity: verifiedIdentity,
837
+ ref,
838
+ path,
839
+ sourceSha,
840
+ };
841
+ this.#rememberPending(pending);
842
+ return {
843
+ deployed: false,
844
+ ref,
845
+ sourceSha,
846
+ warning: "upload-unconfirmed",
847
+ outcome: {
848
+ local: { status: "completed", checkpointSha: sourceSha },
849
+ upload: {
850
+ status: "unconfirmed",
851
+ sourceSha,
852
+ },
853
+ preview: { status: "not-attempted" },
854
+ publication: {
855
+ status: "pending",
856
+ recoveryId: pending.recoveryId,
857
+ },
858
+ },
859
+ };
860
+ }
861
+ throw error;
862
+ }
555
863
  if (sourcePush.remoteStatus !== "pushed") {
556
864
  throw new DeploymentSourcePushError();
557
865
  }
558
- await preflightDeploymentSource(this.#repository, {
559
- remote: this.#runtime.remote,
560
- primaryBranch: this.#runtime.primaryBranch,
561
- sourceSha,
562
- commitIdentity: this.#runtime.commitIdentity,
563
- });
564
- const deployment = await this.#deploymentProvider.deploy({
565
- applicationId: this.#applicationId,
566
- login: verifiedIdentity.login,
567
- });
866
+ let deployment;
867
+ try {
868
+ deployment = await this.#deploymentProvider.deploy({
869
+ applicationId: this.#applicationId,
870
+ login: verifiedIdentity.login,
871
+ });
872
+ }
873
+ catch (error) {
874
+ if (error instanceof DeploymentProviderError &&
875
+ [
876
+ "timeout",
877
+ "transport-failure",
878
+ "invalid-response",
879
+ "invalid-url",
880
+ ].includes(error.kind)) {
881
+ const pending = {
882
+ recoveryId: randomUUID(),
883
+ operation: "deploy",
884
+ kind: "deployment-preview-unconfirmed",
885
+ identity: verifiedIdentity,
886
+ ref,
887
+ path,
888
+ sourceSha,
889
+ };
890
+ this.#rememberPending(pending);
891
+ return {
892
+ deployed: false,
893
+ ref,
894
+ sourceSha,
895
+ warning: "preview-unconfirmed",
896
+ outcome: {
897
+ local: { status: "completed", checkpointSha: sourceSha },
898
+ upload: { status: "confirmed", sourceSha },
899
+ preview: { status: "unconfirmed" },
900
+ publication: { status: "not-attempted" },
901
+ },
902
+ };
903
+ }
904
+ throw error;
905
+ }
568
906
  if (deployment.commitSha.toLowerCase() !== sourceSha ||
569
907
  !["READY", "QUEUED", "INITIALIZING", "BUILDING"].includes(deployment.readyState)) {
570
908
  throw new DeploymentNotAcceptedError();
571
909
  }
572
910
  const previewUrl = new URL(getPrototypeUrl(ref).slice(1), `${deployment.url.replace(/\/+$/, "")}/`).toString();
573
911
  const pending = {
912
+ recoveryId: randomUUID(),
913
+ operation: "deploy",
914
+ kind: "deployment-publication",
574
915
  identity: verifiedIdentity,
575
- input: normalizedInput,
576
916
  ref,
577
917
  path,
578
918
  previewUrl,
579
919
  deploymentId: deployment.deploymentId,
580
920
  sourceSha,
921
+ acceptedAt: new Date().toISOString(),
581
922
  readyState: deployment.readyState,
582
- synchronizationWarning,
923
+ };
924
+ const acceptedOutcome = {
925
+ local: { status: "completed", checkpointSha: sourceSha },
926
+ upload: { status: "confirmed", sourceSha },
927
+ preview: {
928
+ status: "accepted",
929
+ previewUrl,
930
+ deploymentId: deployment.deploymentId,
931
+ },
932
+ publication: { status: "pending" },
583
933
  };
584
934
  try {
585
935
  const publication = await this.#publishDeploymentMetadata(pending);
586
- this.#pendingPublication = undefined;
587
- return this.#deploymentValue(pending, publication.cleanupFailed ? ["publication-cleanup-failed"] : []);
936
+ this.#clearPending(verifiedIdentity.login);
937
+ return this.#deploymentValue(pending, publication.cleanupFailed ? ["publication-cleanup-failed"] : [], {
938
+ ...acceptedOutcome,
939
+ publication: {
940
+ status: "confirmed",
941
+ revision: publication.publicationSha,
942
+ },
943
+ });
588
944
  }
589
945
  catch (error) {
590
946
  // Keep the accepted deployment retryable for every publication
591
947
  // failure, including metadata preparation failures.
592
- this.#pendingPublication = pending;
948
+ this.#rememberPending(pending);
593
949
  return this.#deploymentValue(pending, [
594
950
  error instanceof DeploymentWorktreeError &&
595
- error.kind !== "cleanup-failure"
951
+ error.kind !== "cleanup-failure" &&
952
+ error.kind !== "metadata-failure"
596
953
  ? "primary-merge-failed"
597
954
  : "metadata-persistence-failed",
598
955
  ...(error instanceof DeploymentWorktreeError &&
599
956
  error.cleanupFailed
600
957
  ? ["publication-cleanup-failed"]
601
958
  : []),
602
- ]);
959
+ ], {
960
+ ...acceptedOutcome,
961
+ publication: {
962
+ status: "failed",
963
+ recoveryId: pending.recoveryId,
964
+ },
965
+ });
603
966
  }
604
967
  });
605
968
  });
606
969
  }
607
- async retryPublish(identity, input) {
970
+ async getPendingRecovery(identity) {
971
+ return resultFrom(async () => {
972
+ const verifiedIdentity = await this.#verifiedIdentity(identity);
973
+ const pending = pendingPublications.get(this.#pendingKey(verifiedIdentity.login));
974
+ if (pending === undefined)
975
+ return null;
976
+ return this.#recoveryValue(pending, {
977
+ local: { status: "completed", checkpointSha: pending.sourceSha },
978
+ upload: {
979
+ status: pending.kind === "deployment-upload" ? "unconfirmed" : "confirmed",
980
+ sourceSha: pending.sourceSha,
981
+ },
982
+ preview: pending.kind === "deployment-publication"
983
+ ? {
984
+ status: "accepted",
985
+ previewUrl: pending.previewUrl,
986
+ deploymentId: pending.deploymentId,
987
+ }
988
+ : pending.kind === "deployment-preview-unconfirmed"
989
+ ? { status: "unconfirmed" }
990
+ : { status: "not-attempted" },
991
+ publication: {
992
+ status: pending.kind === "deployment-upload"
993
+ ? "pending"
994
+ : pending.kind === "deployment-preview-unconfirmed"
995
+ ? "not-attempted"
996
+ : "failed",
997
+ ...(pending.kind === "deployment-preview-unconfirmed"
998
+ ? {}
999
+ : { recoveryId: pending.recoveryId }),
1000
+ },
1001
+ });
1002
+ });
1003
+ }
1004
+ async retryPendingPublication(identity, recoveryId) {
608
1005
  if (this.#runtime.gitMode === "manual") {
609
1006
  return lifecycleFailure("forbidden");
610
1007
  }
611
1008
  return resultFrom(async () => {
612
1009
  const verifiedIdentity = await this.#verifiedIdentity(identity);
613
- const normalizedInput = parsePrototypeCommandInput(input);
614
- const pending = this.#pendingPublication;
615
- if (pending === undefined ||
616
- pending.identity.login !== verifiedIdentity.login ||
617
- pending.ref.login !== normalizedInput.ref.login ||
618
- pending.ref.slug !== normalizedInput.ref.slug) {
1010
+ const pending = pendingPublications.get(this.#pendingKey(verifiedIdentity.login));
1011
+ if (pending === undefined || pending.recoveryId !== recoveryId) {
619
1012
  throw new DeploymentUnavailableError();
620
1013
  }
621
1014
  return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
622
1015
  await assertIdentityBranchReady(this.#repository, verifiedIdentity.login);
623
- const publication = await this.#publishDeploymentMetadata(pending);
624
- this.#pendingPublication = undefined;
625
- return this.#deploymentValue(pending, publication.cleanupFailed ? ["publication-cleanup-failed"] : []);
1016
+ if (pending.kind === "deployment-preview-unconfirmed") {
1017
+ throw new DeploymentUnavailableError();
1018
+ }
1019
+ let deploymentPending = pending;
1020
+ if (pending.kind === "ordinary-publication") {
1021
+ deploymentPending = {
1022
+ ...pending,
1023
+ sourceSha: await this.#headCommit(),
1024
+ };
1025
+ this.#rememberPending(deploymentPending);
1026
+ }
1027
+ if (pending.kind === "deployment-upload") {
1028
+ const remoteSource = await remoteBranchCommit(this.#repository, this.#runtime.remote, verifiedIdentity.login);
1029
+ if (remoteSource !== pending.sourceSha) {
1030
+ const upload = await pushCommitToBranch(this.#repository, this.#runtime.remote, pending.sourceSha, verifiedIdentity.login, true);
1031
+ if (upload.remoteStatus !== "pushed") {
1032
+ throw new DeploymentSourcePushError();
1033
+ }
1034
+ }
1035
+ if (!this.#deploymentProvider) {
1036
+ throw new DeploymentUnavailableError();
1037
+ }
1038
+ let deployment;
1039
+ try {
1040
+ deployment = await this.#deploymentProvider.deploy({
1041
+ applicationId: this.#applicationId,
1042
+ login: verifiedIdentity.login,
1043
+ });
1044
+ }
1045
+ catch (error) {
1046
+ if (error instanceof DeploymentProviderError &&
1047
+ [
1048
+ "timeout",
1049
+ "transport-failure",
1050
+ "invalid-response",
1051
+ "invalid-url",
1052
+ ].includes(error.kind)) {
1053
+ const uncertain = {
1054
+ ...pending,
1055
+ kind: "deployment-preview-unconfirmed",
1056
+ };
1057
+ this.#rememberPending(uncertain);
1058
+ return this.#recoveryValue(uncertain, {
1059
+ local: {
1060
+ status: "completed",
1061
+ checkpointSha: pending.sourceSha,
1062
+ },
1063
+ upload: {
1064
+ status: "confirmed",
1065
+ sourceSha: pending.sourceSha,
1066
+ },
1067
+ preview: { status: "unconfirmed" },
1068
+ publication: { status: "not-attempted" },
1069
+ });
1070
+ }
1071
+ throw error;
1072
+ }
1073
+ if (deployment.commitSha.toLowerCase() !== pending.sourceSha ||
1074
+ !["READY", "QUEUED", "INITIALIZING", "BUILDING"].includes(deployment.readyState)) {
1075
+ throw new DeploymentNotAcceptedError();
1076
+ }
1077
+ deploymentPending = {
1078
+ ...pending,
1079
+ kind: "deployment-publication",
1080
+ previewUrl: new URL(getPrototypeUrl(pending.ref).slice(1), `${deployment.url.replace(/\/+$/, "")}/`).toString(),
1081
+ deploymentId: deployment.deploymentId,
1082
+ acceptedAt: new Date().toISOString(),
1083
+ readyState: deployment.readyState,
1084
+ };
1085
+ this.#rememberPending(deploymentPending);
1086
+ }
1087
+ const publication = deploymentPending.kind === "deployment-publication"
1088
+ ? await this.#publishDeploymentMetadata(deploymentPending)
1089
+ : await this.#publishCommittedNamespace(verifiedIdentity, deploymentPending.sourceSha);
1090
+ this.#clearPending(verifiedIdentity.login);
1091
+ return this.#recoveryValue(deploymentPending, {
1092
+ local: {
1093
+ status: "completed",
1094
+ checkpointSha: deploymentPending.sourceSha,
1095
+ },
1096
+ upload: {
1097
+ status: "confirmed",
1098
+ sourceSha: deploymentPending.sourceSha,
1099
+ },
1100
+ preview: deploymentPending.kind === "deployment-publication"
1101
+ ? {
1102
+ status: "accepted",
1103
+ previewUrl: deploymentPending.previewUrl,
1104
+ deploymentId: deploymentPending.deploymentId,
1105
+ }
1106
+ : { status: "not-attempted" },
1107
+ publication: {
1108
+ status: "confirmed",
1109
+ revision: publication.publicationSha,
1110
+ },
1111
+ });
626
1112
  });
627
1113
  });
628
1114
  }
1115
+ /** Compatibility wrapper; recovery-ID retry is the implementation. */
1116
+ async retryPublish(identity, input) {
1117
+ if (this.#runtime.gitMode === "manual") {
1118
+ return lifecycleFailure("forbidden");
1119
+ }
1120
+ let normalizedInput;
1121
+ try {
1122
+ normalizedInput = parsePrototypeCommandInput(input);
1123
+ }
1124
+ catch {
1125
+ return lifecycleFailure("invalid-input");
1126
+ }
1127
+ const pending = pendingPublications.get(this.#pendingKey(identity.login));
1128
+ if (pending === undefined ||
1129
+ pending.kind !== "deployment-publication" ||
1130
+ pending.ref.login !== normalizedInput.ref.login ||
1131
+ pending.ref.slug !== normalizedInput.ref.slug) {
1132
+ return lifecycleFailure("deployment-unavailable");
1133
+ }
1134
+ const result = await this.retryPendingPublication(identity, pending.recoveryId);
1135
+ if (!result.ok)
1136
+ return result;
1137
+ const current = pendingPublications.get(this.#pendingKey(identity.login));
1138
+ const completed = {
1139
+ ...pending,
1140
+ ...(current ?? {}),
1141
+ sourceSha: result.value.sourceSha,
1142
+ ...(result.value.previewUrl === undefined
1143
+ ? {}
1144
+ : { previewUrl: result.value.previewUrl }),
1145
+ ...(result.value.deploymentId === undefined
1146
+ ? {}
1147
+ : { deploymentId: result.value.deploymentId }),
1148
+ };
1149
+ return lifecycleSuccess(await this.#deploymentValue(completed, [], result.value.outcome));
1150
+ }
629
1151
  async save(identity, input) {
630
1152
  if (this.#runtime.gitMode === "manual") {
631
1153
  return lifecycleFailure("forbidden");
632
1154
  }
633
1155
  return resultFrom(async () => {
634
1156
  const verifiedIdentity = await this.#verifiedIdentity(identity);
635
- const { ref, path } = await this.#ownedPrototype(verifiedIdentity, input);
636
1157
  return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
637
1158
  await this.#prepareMutationBranch(verifiedIdentity.login);
638
- await commitPath(this.#repository, path, `save: ${ref.slug}`);
639
- await pushBranch(this.#repository, this.#runtime.remote, verifiedIdentity.login);
1159
+ const { ref, path, directorySnapshot } = await this.#authorizedPrototype(verifiedIdentity, input);
1160
+ await assertPrototypeDirectory(this.#runtime, ref, directorySnapshot);
1161
+ await commitPrototypePath(this.#repository, path, `save: ${ref.slug}`);
640
1162
  return { saved: true };
641
1163
  });
642
1164
  });
@@ -647,9 +1169,11 @@ export class LocalPrototypeLifecycle {
647
1169
  }
648
1170
  return resultFrom(async () => {
649
1171
  const verifiedIdentity = await this.#verifiedIdentity(identity);
650
- const { path } = await this.#ownedPrototype(verifiedIdentity, input);
651
1172
  return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
652
- await discardPath(this.#repository, path);
1173
+ await this.#prepareMutationBranch(verifiedIdentity.login);
1174
+ const { ref, path, directorySnapshot } = await this.#authorizedPrototype(verifiedIdentity, input);
1175
+ await assertPrototypeDirectory(this.#runtime, ref, directorySnapshot);
1176
+ await discardPrototypePath(this.#repository, path);
653
1177
  return { discarded: true };
654
1178
  });
655
1179
  });