create-principles-disciple 1.131.2 → 1.132.1

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.
@@ -713,8 +713,148 @@ function depsMeaningfullyChanged(oldDeps, newDeps) {
713
713
  return aKeys.some((k, i) => bKeys[i] !== k || a[k] !== b[k]);
714
714
  }
715
715
  /**
716
- * Create the node_modules resolution links for the installed components, if
717
- * missing.
716
+ * Move the current occupant of a dependency slot aside (same-directory
717
+ * rename: same volume, atomic, reversible). The quarantine name starts with
718
+ * a dot and is not a valid package name, so npm module resolution ignores it.
719
+ */
720
+ function quarantineSlot(slot) {
721
+ try {
722
+ const quarantinePath = path.join(path.dirname(slot), `.${path.basename(slot)}.update-quarantine-${Date.now()}`);
723
+ fs.renameSync(slot, quarantinePath);
724
+ return { slot, quarantinePath };
725
+ }
726
+ catch {
727
+ return undefined;
728
+ }
729
+ }
730
+ /**
731
+ * Restore quarantined slots to their original locations (update failure
732
+ * path). The slot at that point holds the link reconciliation created —
733
+ * unlink removes the link itself, never its target. Best-effort, never
734
+ * throws; failures are logged (rc-9: observable degradation).
735
+ */
736
+ function restoreQuarantined(quarantined) {
737
+ for (const entry of [...quarantined].reverse()) {
738
+ try {
739
+ try {
740
+ fs.unlinkSync(entry.slot);
741
+ }
742
+ catch { /* slot may already be gone */ }
743
+ fs.renameSync(entry.quarantinePath, entry.slot);
744
+ }
745
+ catch (error) {
746
+ console.error(`[pd-console:update] failed to restore quarantined dependency entry ${entry.slot}: ${error instanceof Error ? error.message : String(error)}`);
747
+ }
748
+ }
749
+ }
750
+ /**
751
+ * Discard quarantined slots (update success path). The quarantined entries
752
+ * are stale pre-update copies of internal @principles packages; the new
753
+ * canonical components are installed by the copy steps. Best-effort,
754
+ * failures are logged and non-fatal (rc-9).
755
+ */
756
+ function cleanupQuarantined(quarantined) {
757
+ for (const entry of quarantined) {
758
+ try {
759
+ fs.rmSync(entry.quarantinePath, { recursive: true, force: true });
760
+ }
761
+ catch (error) {
762
+ console.warn(`[pd-console:update] failed to clean quarantined dependency entry ${entry.quarantinePath}: ${error instanceof Error ? error.message : String(error)}`);
763
+ }
764
+ }
765
+ }
766
+ /**
767
+ * True when the link at linkPath resolves to exactly `target` (string
768
+ * comparison — the target dir may legitimately not exist yet at
769
+ * reconciliation time). Windows paths compare case-insensitively.
770
+ */
771
+ function linkPointsAt(linkPath, target) {
772
+ const resolved = path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
773
+ const expected = path.resolve(target);
774
+ return process.platform === 'win32'
775
+ ? resolved.toLowerCase() === expected.toLowerCase()
776
+ : resolved === expected;
777
+ }
778
+ function createResolutionLink(linkPath, target) {
779
+ try {
780
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
781
+ if (process.platform === 'win32') {
782
+ fs.symlinkSync(target, linkPath, 'junction');
783
+ }
784
+ else {
785
+ fs.symlinkSync(path.relative(path.dirname(linkPath), target), linkPath, 'dir');
786
+ }
787
+ return undefined;
788
+ }
789
+ catch (error) {
790
+ return `Failed to create runtime resolution link at ${linkPath}: ${error instanceof Error ? error.message : String(error)}`;
791
+ }
792
+ }
793
+ /**
794
+ * Reconcile ONE deployed dependency slot against its canonical target
795
+ * (PRI-665, 2026-09-03 incident). The previous "existsSync → skip" logic
796
+ * silently kept stale PHYSICAL copies of internal @principles packages that
797
+ * legacy installs had left in these slots, so the updated dist resolved the
798
+ * old components and crashed at startup. Semantics:
799
+ *
800
+ * - missing slot → create the link (fresh installs);
801
+ * - link pointing at target → keep (npm- or installer-created);
802
+ * - wrong-target link → quarantine + replace;
803
+ * - physical dir / plain file → quarantine + replace (the incident shape).
804
+ *
805
+ * Returns an error message on failure (the slot is rolled back first), or
806
+ * undefined; a successful quarantine is recorded in `quarantined` for the
807
+ * caller to restore on failure / discard on success.
808
+ */
809
+ function reconcileResolutionLink(linkPath, target, quarantined) {
810
+ let stat;
811
+ try {
812
+ stat = fs.lstatSync(linkPath);
813
+ }
814
+ catch {
815
+ stat = undefined; // ENOENT — create below
816
+ }
817
+ if (stat === undefined) {
818
+ return createResolutionLink(linkPath, target);
819
+ }
820
+ if (stat.isSymbolicLink()) {
821
+ try {
822
+ if (linkPointsAt(linkPath, target))
823
+ return undefined; // correct link — keep
824
+ }
825
+ catch {
826
+ // Unreadable link target — fall through to replace.
827
+ }
828
+ }
829
+ const entry = quarantineSlot(linkPath);
830
+ if (entry === undefined) {
831
+ return `Failed to quarantine the existing dependency entry at ${linkPath} (required to install the canonical resolution link). Resolve any file locks and retry the update.`;
832
+ }
833
+ const error = createResolutionLink(linkPath, target);
834
+ if (error) {
835
+ // Roll this slot back before failing — the install stays untouched
836
+ // (the PRI-561 fail-closed ordering contract).
837
+ try {
838
+ fs.unlinkSync(linkPath);
839
+ }
840
+ catch { /* nothing we created */ }
841
+ try {
842
+ fs.renameSync(entry.quarantinePath, entry.slot);
843
+ }
844
+ catch {
845
+ // The in-slot rename failed (e.g. a transient lock). Hand the entry to
846
+ // the outer rollback so it retries the restore — restoreQuarantined
847
+ // tolerates a missing slot when unlinking before renaming back.
848
+ quarantined.push(entry);
849
+ }
850
+ return error;
851
+ }
852
+ quarantined.push(entry);
853
+ return undefined;
854
+ }
855
+ /**
856
+ * Create or RECONCILE the node_modules resolution links for the installed
857
+ * components.
718
858
  *
719
859
  * Mirrors installer.ts syncPdCli: junction on Windows (no elevation needed),
720
860
  * relative symlink elsewhere. Fresh installs get these links via npm install
@@ -738,8 +878,11 @@ function depsMeaningfullyChanged(oldDeps, newDeps) {
738
878
  * host-runtime/node_modules/@principles/install-layout missing and
739
879
  * every pd-cli runtime command failing with ERR_MODULE_NOT_FOUND.
740
880
  *
741
- * Returns undefined on success, or an error message (rc-9: observable, never
742
- * silent — a missing link means the updated console cannot start).
881
+ * Returns { error } on failure with every quarantine rolled back (rc-9:
882
+ * observable, never silent — a missing or unreconciled link means the
883
+ * updated console cannot start), or { quarantined } listing the dependency
884
+ * entries replaced during reconciliation. The caller restores the
885
+ * quarantined entries if a LATER step fails, and discards them on success.
743
886
  */
744
887
  function ensureRuntimeResolutionLinks(layout, tempDir) {
745
888
  const links = [
@@ -784,47 +927,35 @@ function ensureRuntimeResolutionLinks(layout, tempDir) {
784
927
  }
785
928
  };
786
929
  const fileDepSpecs = collectFileDepLinkSpecs(stagedComponents, readStagedDependencies);
787
- const createResolutionLink = (linkPath, target) => {
788
- // Idempotent: never overwrite an existing link or directory (fresh
789
- // installs have npm-created real dirs in these slots).
790
- if (fs.existsSync(linkPath))
791
- return undefined;
792
- try {
793
- fs.mkdirSync(path.dirname(linkPath), { recursive: true });
794
- if (process.platform === 'win32') {
795
- fs.symlinkSync(target, linkPath, 'junction');
796
- }
797
- else {
798
- fs.symlinkSync(path.relative(path.dirname(linkPath), target), linkPath, 'dir');
799
- }
800
- return undefined;
801
- }
802
- catch (error) {
803
- return `Failed to create runtime resolution link at ${linkPath}: ${error instanceof Error ? error.message : String(error)}`;
804
- }
805
- };
930
+ const quarantined = [];
806
931
  // Pass 1 — explicit known-critical links, fail-closed BEFORE any byte is
807
- // swapped (a link-creation failure aborts with the installed packages
808
- // untouched: the PRI-561 ordering contract).
932
+ // swapped (a link-reconciliation failure aborts with the installed packages
933
+ // untouched: the PRI-561 ordering contract). Existing entries are
934
+ // RECONCILED, not skipped: a correct link is kept, but a stale physical
935
+ // copy or a wrong-target link is quarantined and replaced (PRI-665,
936
+ // 2026-09-03: legacy installs left physical @principles copies in these
937
+ // slots and updated dists crashed resolving them).
809
938
  for (const { linkPath, target } of links) {
810
939
  if (!fs.existsSync(target))
811
940
  continue;
812
- const error = createResolutionLink(linkPath, target);
813
- if (error)
814
- return error;
941
+ const error = reconcileResolutionLink(linkPath, target, quarantined);
942
+ if (error) {
943
+ restoreQuarantined(quarantined);
944
+ return { error, quarantined: [] };
945
+ }
815
946
  }
816
947
  // Pass 2 — data-driven derived links. Their deployed target dir may be
817
948
  // created by the copy steps that follow (a brand-new component's dir does
818
949
  // not exist yet); each staged target's existence was already proven by the
819
950
  // extraction, and the copies below run unconditionally.
820
951
  for (const spec of fileDepSpecs) {
821
- if (fs.existsSync(spec.linkPath))
822
- continue;
823
- const error = createResolutionLink(spec.linkPath, spec.target);
824
- if (error)
825
- return error;
952
+ const error = reconcileResolutionLink(spec.linkPath, spec.target, quarantined);
953
+ if (error) {
954
+ restoreQuarantined(quarantined);
955
+ return { error, quarantined: [] };
956
+ }
826
957
  }
827
- return undefined;
958
+ return { quarantined };
828
959
  }
829
960
  async function doInlineFullUpdate(workspaceDir) {
830
961
  const layout = resolveUpdateLayout();
@@ -861,6 +992,9 @@ async function doInlineFullUpdate(workspaceDir) {
861
992
  // overwrites it (PR #1332 companion — see reapplySkillLanguage).
862
993
  const skillLanguage = detectInstalledSkillLanguage(extDir);
863
994
  let tempDir;
995
+ // Dependency entries quarantined during resolution-link reconciliation;
996
+ // restored on failure, discarded on success (PRI-665).
997
+ let reconciledQuarantine = [];
864
998
  try {
865
999
  // 2. Fetch installer package info from npm
866
1000
  const response = await fetchWithRetry(NPM_REGISTRY_INSTALLER, 'Installer registry check');
@@ -1018,7 +1152,8 @@ async function doInlineFullUpdate(workspaceDir) {
1018
1152
  if (fs.existsSync(path.join(installLayoutSrc, 'package.json')) && fs.existsSync(path.join(installLayoutSrc, 'dist'))) {
1019
1153
  copyDirRecursive(installLayoutSrc, layout.installLayoutDir, SKIP_DIRS);
1020
1154
  }
1021
- const linkError = ensureRuntimeResolutionLinks(layout, tempDir);
1155
+ const { error: linkError, quarantined } = ensureRuntimeResolutionLinks(layout, tempDir);
1156
+ reconciledQuarantine = quarantined;
1022
1157
  if (linkError) {
1023
1158
  appendUpdateHistory(workspaceDir, {
1024
1159
  fromVersion,
@@ -1078,6 +1213,13 @@ async function doInlineFullUpdate(workspaceDir) {
1078
1213
  if (tempDir && fs.existsSync(tempDir)) {
1079
1214
  fs.rmSync(tempDir, { recursive: true, force: true });
1080
1215
  }
1216
+ // 6.5 Discard the quarantined stale dependency copies — the canonical
1217
+ // components are in place now (PRI-665). Both the success return and the
1218
+ // version-drift failure return flow through here with files already
1219
+ // swapped, so restoring the stale copies would re-break resolution.
1220
+ if (reconciledQuarantine.length > 0) {
1221
+ cleanupQuarantined(reconciledQuarantine);
1222
+ }
1081
1223
  // 7. Version-advance check (drift guard). The full update installs the
1082
1224
  // plugin bundled inside the installer. If the installer is stale (its
1083
1225
  // bundled plugin is NOT newer than what is installed), the "update" is a
@@ -1122,6 +1264,12 @@ async function doInlineFullUpdate(workspaceDir) {
1122
1264
  };
1123
1265
  }
1124
1266
  catch (error) {
1267
+ // PRI-665: restore any dependency slots quarantined during link
1268
+ // reconciliation FIRST — a failed update must not leave the install
1269
+ // half-migrated (new dist with the old resolution quarantined away).
1270
+ if (reconciledQuarantine.length > 0) {
1271
+ restoreQuarantined(reconciledQuarantine);
1272
+ }
1125
1273
  // Clean up temp dir on failure
1126
1274
  if (tempDir && fs.existsSync(tempDir)) {
1127
1275
  try {
@@ -3,6 +3,7 @@ import { type ComponentStatus, type VerificationResult } from './mvp-config.js';
3
3
  import { type HostTarget } from './installers/index.js';
4
4
  import type { HostInstallResult } from '@principles/core/host';
5
5
  import { type SkillLanguage } from './skill-language.js';
6
+ import { type ReleaseMetadataDigestSource, type TransactionState } from './update/transaction-journal.js';
6
7
  /** PRI-343: Keep in sync with @principles/core CONVERSATION_ACCESS_CONFIG_KEY */
7
8
  export declare const CONVERSATION_ACCESS_CONFIG_KEY: 'allowConversationAccess';
8
9
  /**
@@ -137,6 +138,19 @@ export interface InstallResult {
137
138
  consoleUrl?: string;
138
139
  /** ADR-0020 §2.3: Host-side install results (one per HostInstaller). */
139
140
  hostResults?: HostInstallResult[];
141
+ /** ADR-0024 D-2 (PRI-664): transaction journal record for this install.
142
+ * Undefined when the mutation never began (pre-mutation refusal/throw). */
143
+ journal?: InstallJournalRecord;
144
+ }
145
+ /** Observability record for one installer transaction (ADR-0024 D-2). */
146
+ export interface InstallJournalRecord {
147
+ readonly transactionId: string;
148
+ /** Journal file: `~/.pd/transactions/<transactionId>.jsonl` (D-6, runtime scope). */
149
+ readonly journalPath: string;
150
+ /** True when a mid-flight journal append failed (Tier-2 degradation) and
151
+ * later transitions were skipped — the backup/restore safety net remained
152
+ * authoritative for this transaction. */
153
+ readonly degraded: boolean;
140
154
  }
141
155
  export interface InstallRunMode {
142
156
  /** Suppress human output (spinner / progress). True under --json. */
@@ -144,6 +158,28 @@ export interface InstallRunMode {
144
158
  /** No interactive prompts. True under --yes / --non-interactive / --json. */
145
159
  nonInteractive?: boolean;
146
160
  }
161
+ interface InstallerJournal {
162
+ readonly transactionId: string;
163
+ readonly journalPath: string;
164
+ readonly releaseId: string;
165
+ readonly productVersion: string;
166
+ readonly releaseMetadataDigest: string;
167
+ /** PRI-664 review: provenance of releaseMetadataDigest ('manifest' | 'package_manifest' | 'fallback'). */
168
+ readonly releaseMetadataDigestSource: ReleaseMetadataDigestSource;
169
+ degraded: boolean;
170
+ /** Last successfully journaled state — the `from` for failure-path transitions. */
171
+ lastState: TransactionState | null;
172
+ }
173
+ /** Opens one installer transaction: `~/.pd/transactions/<transactionId>.jsonl`. */
174
+ export declare function beginInstallerJournal(pluginDir: string): InstallerJournal;
175
+ /**
176
+ * Append one transition durably (append + fsync) BEFORE the side effect it
177
+ * describes. Throws on failure — the caller decides Tier-1 (refuse before
178
+ * mutation) vs Tier-2 (degrade and continue).
179
+ */
180
+ export declare function journalInstallerTransition(journal: InstallerJournal, from: TransactionState | null, to: TransactionState, detail: string): void;
181
+ /** Tier-2 wrapper: on append failure, degrade (mark + warn) instead of throwing. */
182
+ export declare function journalInstallerTransitionDegrading(journal: InstallerJournal, from: TransactionState | null, to: TransactionState, detail: string): void;
147
183
  export declare function install(options: InstallOptions, pluginDir: string, mode?: InstallRunMode): Promise<InstallResult>;
148
184
  export {};
149
185
  //# sourceMappingURL=installer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"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"}
package/dist/installer.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, cpSync, renameSync, chmodSync, symlinkSync } from 'fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
2
3
  import fse from 'fs-extra';
3
4
  import * as path from 'path';
4
5
  import * as http from 'http';
@@ -13,6 +14,7 @@ import { getHostInstallers } from './installers/index.js';
13
14
  import { mergeInstallManifestWorkspaces, parseInstallManifest } from '@principles/install-layout';
14
15
  import { applySkillLanguageSelection } from './skill-language.js';
15
16
  import { parseReleaseAssetIdentity, parseReleaseAssetManifest, ReleaseAssetManifestError, verifyReleaseAssetManifestAsync, verifyReleaseAssetTarget, } from './update/release-asset-manifest.js';
17
+ import { appendJournalTransition } from './update/transaction-journal.js';
16
18
  /** PRI-343: Keep in sync with @principles/core CONVERSATION_ACCESS_CONFIG_KEY */
17
19
  export const CONVERSATION_ACCESS_CONFIG_KEY = 'allowConversationAccess';
18
20
  function isRecord(value) {
@@ -1645,6 +1647,98 @@ async function runHostInstallers(host, ctx) {
1645
1647
  }
1646
1648
  return results;
1647
1649
  }
1650
+ function sha256File(filePath) {
1651
+ return createHash('sha256').update(readFileSync(filePath)).digest('hex');
1652
+ }
1653
+ /**
1654
+ * Identity of the payload being installed. Prefers the self-contained asset
1655
+ * manifest (covers the whole payload); falls back to the bundled pd-cli
1656
+ * package manifest. Both are REAL digests — the journal never stores a
1657
+ * placeholder where a verifiable value is available (same discipline as
1658
+ * legacy-migration.ts). The last-resort fallback hashes the literal reason
1659
+ * string only to satisfy the journal's 64-hex format requirement; it is not
1660
+ * part of any release-metadata identity chain.
1661
+ */
1662
+ function resolveInstallerPayloadIdentity(pluginDir) {
1663
+ let productVersion = 'unknown';
1664
+ const pdCliPkgPath = path.join(pluginDir, 'pd-cli', 'package.json');
1665
+ if (existsSync(pdCliPkgPath)) {
1666
+ try {
1667
+ const parsed = JSON.parse(readFileSync(pdCliPkgPath, 'utf8'));
1668
+ if (typeof parsed.version === 'string' && parsed.version.length > 0)
1669
+ productVersion = parsed.version;
1670
+ }
1671
+ catch {
1672
+ // Identity falls back to 'unknown'; journaling must not brick install.
1673
+ }
1674
+ }
1675
+ const assetManifestPath = path.join(pluginDir, '_release', 'manifest.json');
1676
+ let releaseMetadataDigest;
1677
+ let releaseMetadataDigestSource;
1678
+ if (existsSync(assetManifestPath)) {
1679
+ releaseMetadataDigest = sha256File(assetManifestPath);
1680
+ releaseMetadataDigestSource = 'manifest';
1681
+ }
1682
+ else if (existsSync(pdCliPkgPath)) {
1683
+ releaseMetadataDigest = sha256File(pdCliPkgPath);
1684
+ releaseMetadataDigestSource = 'package_manifest';
1685
+ }
1686
+ else {
1687
+ releaseMetadataDigest = createHash('sha256').update('installer-payload-missing-identity').digest('hex');
1688
+ releaseMetadataDigestSource = 'fallback';
1689
+ }
1690
+ return { productVersion, releaseMetadataDigest, releaseMetadataDigestSource };
1691
+ }
1692
+ /** Opens one installer transaction: `~/.pd/transactions/<transactionId>.jsonl`. */
1693
+ export function beginInstallerJournal(pluginDir) {
1694
+ const { productVersion, releaseMetadataDigest, releaseMetadataDigestSource } = resolveInstallerPayloadIdentity(pluginDir);
1695
+ const transactionId = `install-${Date.now()}-${randomUUID().slice(0, 8)}`;
1696
+ const journalPath = path.join(getPdDir(), 'transactions', `${transactionId}.jsonl`);
1697
+ const releaseId = `bundled-${productVersion}-${releaseMetadataDigest.slice(0, 12)}`;
1698
+ return { transactionId, journalPath, releaseId, productVersion, releaseMetadataDigest, releaseMetadataDigestSource, degraded: false, lastState: null };
1699
+ }
1700
+ /**
1701
+ * Append one transition durably (append + fsync) BEFORE the side effect it
1702
+ * describes. Throws on failure — the caller decides Tier-1 (refuse before
1703
+ * mutation) vs Tier-2 (degrade and continue).
1704
+ */
1705
+ // eslint-disable-next-line @typescript-eslint/max-params -- (from, to, detail) mirrors the JournalTransition shape it appends
1706
+ export function journalInstallerTransition(journal, from, to, detail) {
1707
+ appendJournalTransition(journal.journalPath, {
1708
+ at: new Date().toISOString(),
1709
+ from,
1710
+ to,
1711
+ transactionId: journal.transactionId,
1712
+ releaseId: journal.releaseId,
1713
+ productVersion: journal.productVersion,
1714
+ releaseMetadataDigest: journal.releaseMetadataDigest,
1715
+ releaseMetadataDigestSource: journal.releaseMetadataDigestSource,
1716
+ // The installer model has no active.json generation chain yet (it never
1717
+ // writes active.json); generation continuity becomes ReleaseManager's
1718
+ // responsibility when it takes over mutations (PRI-661).
1719
+ generation: 1,
1720
+ detail,
1721
+ });
1722
+ journal.lastState = to;
1723
+ }
1724
+ /** Tier-2 wrapper: on append failure, degrade (mark + warn) instead of throwing. */
1725
+ // eslint-disable-next-line @typescript-eslint/max-params -- (from, to, detail) mirrors the JournalTransition shape it appends
1726
+ export function journalInstallerTransitionDegrading(journal, from, to, detail) {
1727
+ if (journal.degraded)
1728
+ return;
1729
+ try {
1730
+ journalInstallerTransition(journal, from, to, detail);
1731
+ }
1732
+ catch (error) {
1733
+ journal.degraded = true;
1734
+ logger.error(`Transaction journal append failed at '${to}' — continuing under the backup/restore safety net; `
1735
+ + `this transaction is partially journaled (ADR-0024 D-2 Tier-2 degradation): `
1736
+ + `${error instanceof Error ? error.message : String(error)}`);
1737
+ }
1738
+ }
1739
+ function installerJournalRecord(journal) {
1740
+ return { transactionId: journal.transactionId, journalPath: journal.journalPath, degraded: journal.degraded };
1741
+ }
1648
1742
  export async function install(options, pluginDir, mode = {}) {
1649
1743
  // `quiet` (= jsonMode) suppresses human output / spinner. `nonInteractive`
1650
1744
  // (--yes/--non-interactive/--json) gates PROMPTING. They differ for `--yes`
@@ -1726,6 +1820,8 @@ export async function install(options, pluginDir, mode = {}) {
1726
1820
  const spinner = quiet ? null : ora('Installing...').start();
1727
1821
  let backupDir = null;
1728
1822
  let runtimeBackupDir = null;
1823
+ // ADR-0024 D-2: non-null once the transaction is planned (first mutation is imminent).
1824
+ let journal = null;
1729
1825
  let installManifestHosts;
1730
1826
  const components = { plugin: 'skipped', cli: 'skipped', console: 'skipped' };
1731
1827
  const verification = { features: 'skipped', storyA: 'skipped' };
@@ -1767,6 +1863,34 @@ export async function install(options, pluginDir, mode = {}) {
1767
1863
  stepIndex++;
1768
1864
  if (spinner)
1769
1865
  updateProgress(spinner, stepIndex, 'Backing up existing install...');
1866
+ // ADR-0024 D-2 (PRI-664): journal-first — record 'planned' BEFORE the
1867
+ // first runtime mutation (the backup rename). Tier-1 policy: if the
1868
+ // journal cannot be written at all, refuse before mutating (fail loud,
1869
+ // zero side effects) rather than performing an unjournaled mutation
1870
+ // (refusal over silent degradation, ADR-0024 §2.4 rule 4).
1871
+ journal = beginInstallerJournal(pluginDir);
1872
+ try {
1873
+ journalInstallerTransition(journal, null, 'planned', `host=${options.host} mode=${options.mode}`);
1874
+ }
1875
+ catch (journalError) {
1876
+ if (spinner)
1877
+ spinner.fail('Install failed');
1878
+ const journalDetail = journalError instanceof Error ? journalError.message : String(journalError);
1879
+ logger.error(`Transaction journal unavailable — refusing to mutate the runtime unjournaled (ADR-0024 D-2): ${journalDetail}`);
1880
+ return {
1881
+ success: false,
1882
+ workspaceDir: options.workspaceDir,
1883
+ configYamlPath: getConfigYamlPath(options.workspaceDir),
1884
+ templatesCount: 0,
1885
+ components,
1886
+ verification,
1887
+ enabledChannels: options.channels,
1888
+ nextAction: 'Resolve write access to ~/.pd/transactions (disk space / permissions), then re-run the installer. No changes were made.',
1889
+ reason: `transaction_journal_unavailable: ${journalDetail}`,
1890
+ error: `Could not write the transaction journal — refusing to mutate the runtime unjournaled (ADR-0024 D-2). No changes were made.`,
1891
+ journal: { transactionId: journal.transactionId, journalPath: journal.journalPath, degraded: true },
1892
+ };
1893
+ }
1770
1894
  // Validate the existing host-ownership record before mutating runtime or
1771
1895
  // host config. A malformed manifest must not be discovered only after the
1772
1896
  // old installation has already been replaced (rc-3/rc-9).
@@ -1847,6 +1971,9 @@ export async function install(options, pluginDir, mode = {}) {
1847
1971
  updateProgress(spinner, stepIndex, 'Validating bundled console dependencies...');
1848
1972
  await installConsoleDependencies();
1849
1973
  stepIndex++;
1974
+ // ADR-0024 D-2: all runtime content is laid down — the new installation
1975
+ // is staged (nothing has been discarded yet; backups still hold the old one).
1976
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'staged', 'runtime components installed');
1850
1977
  if (spinner)
1851
1978
  updateProgress(spinner, stepIndex, 'Verifying pd-console...');
1852
1979
  const consoleVerify = await verifyConsole(options.workspaceDir);
@@ -1859,6 +1986,8 @@ export async function install(options, pluginDir, mode = {}) {
1859
1986
  throw new Error(`Console verification failed: ${consoleVerify.reason ?? 'unknown'}. Installation rolled back — plugin and CLI are not activated.`);
1860
1987
  }
1861
1988
  stepIndex++;
1989
+ // ADR-0024 D-2: the console probe passed — the staged installation is live.
1990
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'probed', `console verified at ${consoleVerify.url}`);
1862
1991
  if (spinner)
1863
1992
  updateProgress(spinner, stepIndex, 'Copying templates...');
1864
1993
  const templatesCount = await copyCoreTemplates({
@@ -1965,7 +2094,13 @@ export async function install(options, pluginDir, mode = {}) {
1965
2094
  throw new Error(`Host installation failed: ${hostFailures.join(' | ')}`);
1966
2095
  }
1967
2096
  writeInstallManifest(installManifestHosts, resolveInstallManifestWorkspaces(options.workspaceDir));
2097
+ // ADR-0024 D-2: host installers completed and the install manifest is
2098
+ // written — the new installation is fully activated (backups not yet
2099
+ // discarded, so a crash here still recovers via the backup).
2100
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'activated', 'host installers completed; install manifest written');
1968
2101
  cleanupBackup(backupDir, runtimeBackupDir);
2102
+ // ADR-0024 D-2: backup cleanup is the commit point of the transaction.
2103
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'confirmed', 'backup cleaned up; install complete');
1969
2104
  if (spinner) {
1970
2105
  spinner.succeed('Install complete!');
1971
2106
  }
@@ -2015,14 +2150,35 @@ export async function install(options, pluginDir, mode = {}) {
2015
2150
  nextAction: nextActions.join(' | '),
2016
2151
  consoleUrl: launchResult.consoleUrl,
2017
2152
  hostResults,
2153
+ journal: installerJournalRecord(journal),
2018
2154
  };
2019
2155
  }
2020
2156
  catch (error) {
2021
2157
  if (spinner)
2022
2158
  spinner.fail('Install failed');
2023
2159
  killConsoleChild();
2160
+ // ADR-0024 D-2: record the outcome (journal may be null when the throw
2161
+ // happened before the transaction was planned — zero side effects then).
2162
+ // `failed` and `rolled_back` are BOTH terminal states and one journal
2163
+ // file must end at exactly one terminal state (the strict reader rejects
2164
+ // any transition after a terminal one), so the outcome is either-or:
2165
+ // - backup restored (a real rollback happened) → `rolled_back`
2166
+ // - no backup existed (mutation never started) or restore failed → `failed`
2167
+ const errorMsgRaw = error instanceof Error ? error.message : String(error);
2024
2168
  const restoreResult = restoreBackup(backupDir, runtimeBackupDir);
2025
- const errorMsg = error instanceof Error ? error.message : String(error);
2169
+ if (journal) {
2170
+ const restoreFailed = Boolean(backupDir || runtimeBackupDir) && !restoreResult.restored;
2171
+ const detail = restoreFailed
2172
+ ? `${errorMsgRaw}; backup restore FAILED: ${restoreResult.error ?? 'unknown'}`
2173
+ : errorMsgRaw;
2174
+ if ((backupDir || runtimeBackupDir) && restoreResult.restored) {
2175
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'rolled_back', detail);
2176
+ }
2177
+ else {
2178
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'failed', detail);
2179
+ }
2180
+ }
2181
+ const errorMsg = errorMsgRaw;
2026
2182
  // ERR-046 / rc-9: never claim a restore that didn't happen. When backupDir
2027
2183
  // is null, the backup step never completed (it threw — e.g. EPERM — or
2028
2184
  // there was no existing install), so the existing install was never moved
@@ -2074,6 +2230,7 @@ export async function install(options, pluginDir, mode = {}) {
2074
2230
  component: error instanceof SelfContainedDependencyError ? error.component : undefined,
2075
2231
  dependency: error instanceof SelfContainedDependencyError ? error.dependency : undefined,
2076
2232
  error: `${errorMsg} — ${rollbackSuffix}`,
2233
+ journal: journal ? installerJournalRecord(journal) : undefined,
2077
2234
  };
2078
2235
  }
2079
2236
  finally {