@principles/install-layout 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -6,7 +6,23 @@ export type InstallHost = (typeof INSTALL_HOSTS)[number];
6
6
  export interface InstallManifest {
7
7
  layoutVersion: typeof INSTALL_LAYOUT_VERSION;
8
8
  mode: InstallLayoutMode;
9
+ /**
10
+ * Hosts THIS reader version recognizes. Host strings the reader does not
11
+ * know are NOT here — they land in `unknownHosts` so an install registered
12
+ * with a newer host keeps resolving instead of being rejected as malformed.
13
+ */
9
14
  hosts: InstallHost[];
15
+ /**
16
+ * Host strings present in the persisted `hosts` array that this reader
17
+ * version does not know (forward compatibility, 2026-10-16). A NEWER
18
+ * installer may register a host an OLDER reader never heard of; that is a
19
+ * compatibility warning, never "manifest malformed" — treating it as one
20
+ * made every pre-WorkBuddy Companion report `console_runtime_not_installed`
21
+ * against a healthy 3-host install and mass-stop its workers. Fail-closed
22
+ * stays reserved for structural damage: wrong layoutVersion, non-array or
23
+ * non-string hosts, bad mode, illegal workspace paths.
24
+ */
25
+ unknownHosts?: readonly string[];
10
26
  /**
11
27
  * Canonical absolute paths of Workspaces the install serves (PRI-624 Slice C).
12
28
  * Optional so pre-PRI-624 manifests stay valid; always normalized to
@@ -16,6 +32,11 @@ export interface InstallManifest {
16
32
  */
17
33
  workspaces?: readonly string[];
18
34
  }
35
+ /** Structured forward-compatibility observation from manifest parsing (rc-9). */
36
+ export interface InstallManifestWarning {
37
+ code: 'install_manifest_unknown_host';
38
+ host: string;
39
+ }
19
40
  export interface InstallLayoutPaths {
20
41
  homeDir: string;
21
42
  pdDir: string;
@@ -58,6 +79,8 @@ export interface LayoutResolution {
58
79
  manifest?: InstallManifest;
59
80
  reason?: string;
60
81
  nextAction?: string;
82
+ /** Forward-compatibility warnings surfaced while parsing the manifest. */
83
+ warnings?: readonly InstallManifestWarning[];
61
84
  }
62
85
  export interface ResolveInstallLayoutOptions {
63
86
  homeDir: string;
@@ -65,10 +88,26 @@ export interface ResolveInstallLayoutOptions {
65
88
  canonicalRuntimeExists: boolean;
66
89
  legacyExtensionExists: boolean;
67
90
  }
68
- /** Merge one Workspace into a manifest's workspace list (installer write path). Idempotent by canonical path. */
91
+ /**
92
+ * Merge one Workspace into a manifest's workspace list (installer write path). Idempotent by canonical path. */
69
93
  export declare function mergeInstallManifestWorkspaces(current: InstallManifest | undefined, workspaceDir: string): string[];
94
+ /**
95
+ * The `hosts` array a manifest WRITER must persist: every registration the
96
+ * reader recognized plus every unknown one, deduped, known hosts first.
97
+ * Writers that rebuild `hosts` from `manifest.hosts` alone silently DELETE a
98
+ * newer host's registration — the write-side twin of the read-side
99
+ * unknown-host brick (2026-10-16). `undefined` yields `[]` so the installer's
100
+ * fresh-install path keeps working unchanged.
101
+ */
102
+ export declare function installManifestHostsForPersist(manifest: InstallManifest | undefined): string[];
103
+ /**
104
+ * Parse the install manifest. Structural damage fails closed with `error`;
105
+ * a host string this reader version does not know is a `warnings` entry and
106
+ * the manifest still parses (forward compatibility — see `InstallManifest.unknownHosts`).
107
+ */
70
108
  export declare function parseInstallManifest(value: unknown): {
71
109
  manifest?: InstallManifest;
110
+ warnings?: readonly InstallManifestWarning[];
72
111
  error?: string;
73
112
  };
74
113
  export declare function getInstallLayoutPaths(homeDir: string): InstallLayoutPaths;
package/dist/index.js CHANGED
@@ -8,6 +8,9 @@ function isRecord(value) {
8
8
  function isHost(value) {
9
9
  return typeof value === 'string' && INSTALL_HOSTS.includes(value);
10
10
  }
11
+ function isNonEmptyString(value) {
12
+ return typeof value === 'string' && value.length > 0;
13
+ }
11
14
  /** Workspace entries must be non-empty absolute paths — the Companion spawns per-Workspace workers keyed by canonical path. */
12
15
  function parseWorkspaceList(value) {
13
16
  if (value === undefined)
@@ -25,7 +28,8 @@ function parseWorkspaceList(value) {
25
28
  }
26
29
  return { workspaces: resolved };
27
30
  }
28
- /** Merge one Workspace into a manifest's workspace list (installer write path). Idempotent by canonical path. */
31
+ /**
32
+ * Merge one Workspace into a manifest's workspace list (installer write path). Idempotent by canonical path. */
29
33
  export function mergeInstallManifestWorkspaces(current, workspaceDir) {
30
34
  const canonical = path.resolve(workspaceDir);
31
35
  const resolved = [];
@@ -38,6 +42,22 @@ export function mergeInstallManifestWorkspaces(current, workspaceDir) {
38
42
  resolved.push(canonical);
39
43
  return resolved;
40
44
  }
45
+ /**
46
+ * The `hosts` array a manifest WRITER must persist: every registration the
47
+ * reader recognized plus every unknown one, deduped, known hosts first.
48
+ * Writers that rebuild `hosts` from `manifest.hosts` alone silently DELETE a
49
+ * newer host's registration — the write-side twin of the read-side
50
+ * unknown-host brick (2026-10-16). `undefined` yields `[]` so the installer's
51
+ * fresh-install path keeps working unchanged.
52
+ */
53
+ export function installManifestHostsForPersist(manifest) {
54
+ return [...new Set([...(manifest?.hosts ?? []), ...(manifest?.unknownHosts ?? [])])];
55
+ }
56
+ /**
57
+ * Parse the install manifest. Structural damage fails closed with `error`;
58
+ * a host string this reader version does not know is a `warnings` entry and
59
+ * the manifest still parses (forward compatibility — see `InstallManifest.unknownHosts`).
60
+ */
41
61
  export function parseInstallManifest(value) {
42
62
  if (!isRecord(value))
43
63
  return { error: 'install_manifest_malformed: expected an object' };
@@ -47,14 +67,55 @@ export function parseInstallManifest(value) {
47
67
  if (value.mode !== 'canonical' && value.mode !== 'legacy') {
48
68
  return { error: 'install_manifest_malformed: mode must be canonical or legacy' };
49
69
  }
50
- if (!Array.isArray(value.hosts) || value.hosts.length === 0 || !value.hosts.every(isHost)) {
51
- return { error: 'install_manifest_malformed: hosts must contain codex, openclaw and/or workbuddy' };
70
+ // Host entries must be non-empty strings; WHICH strings are valid hosts is
71
+ // version-relative knowledge, so unknown strings warn instead of failing.
72
+ if (!Array.isArray(value.hosts) || !value.hosts.every(isNonEmptyString)) {
73
+ return { error: 'install_manifest_malformed: hosts must be an array of host name strings' };
74
+ }
75
+ // Idempotency: the parser must re-read its own output. An all-unknown-hosts
76
+ // manifest parses to hosts: [] + unknownHosts, so the optional persisted
77
+ // `unknownHosts` field is accepted on input too (installManifestHostsForPersist
78
+ // reunifies them on write; writers persist the union into `hosts`).
79
+ let carriedUnknownHosts = [];
80
+ if (value.unknownHosts !== undefined) {
81
+ if (!Array.isArray(value.unknownHosts) || !value.unknownHosts.every(isNonEmptyString)) {
82
+ return { error: 'install_manifest_malformed: unknownHosts must be an array of host name strings' };
83
+ }
84
+ carriedUnknownHosts = value.unknownHosts.filter((host) => !isHost(host));
85
+ }
86
+ if (value.hosts.length === 0 && carriedUnknownHosts.length === 0) {
87
+ return { error: 'install_manifest_malformed: hosts must contain at least one host' };
88
+ }
89
+ const hosts = [];
90
+ const unknownHosts = [];
91
+ const warnings = [];
92
+ for (const host of [...new Set(value.hosts)]) {
93
+ if (isHost(host))
94
+ hosts.push(host);
95
+ else {
96
+ unknownHosts.push(host);
97
+ warnings.push({ code: 'install_manifest_unknown_host', host });
98
+ }
99
+ }
100
+ for (const host of carriedUnknownHosts) {
101
+ if (!unknownHosts.includes(host)) {
102
+ unknownHosts.push(host);
103
+ warnings.push({ code: 'install_manifest_unknown_host', host });
104
+ }
52
105
  }
53
- const hosts = [...new Set(value.hosts)];
54
106
  const workspaces = parseWorkspaceList(value.workspaces);
55
107
  if (workspaces.error)
56
108
  return { error: workspaces.error };
57
- return { manifest: { layoutVersion: INSTALL_LAYOUT_VERSION, mode: value.mode, hosts, workspaces: workspaces.workspaces } };
109
+ return {
110
+ manifest: {
111
+ layoutVersion: INSTALL_LAYOUT_VERSION,
112
+ mode: value.mode,
113
+ hosts,
114
+ ...(unknownHosts.length > 0 ? { unknownHosts } : {}),
115
+ workspaces: workspaces.workspaces,
116
+ },
117
+ ...(warnings.length > 0 ? { warnings } : {}),
118
+ };
58
119
  }
59
120
  export function getInstallLayoutPaths(homeDir) {
60
121
  const home = path.resolve(homeDir);
@@ -96,8 +157,28 @@ export function getWorkbuddyWorkspaceDir(homeDir) {
96
157
  export function resolveInstallLayout(options) {
97
158
  const paths = getInstallLayoutPaths(options.homeDir);
98
159
  const parsed = parseInstallManifest(options.manifest);
160
+ const { warnings } = parsed;
99
161
  if (parsed.manifest && parsed.manifest.mode === 'canonical' && options.canonicalRuntimeExists) {
100
- return { mode: 'canonical', paths, manifest: parsed.manifest };
162
+ return { mode: 'canonical', paths, manifest: parsed.manifest, ...(warnings !== undefined ? { warnings } : {}) };
163
+ }
164
+ // RF-01 (review 2026-10-16): when a canonical runtime EXISTS, the manifest
165
+ // is the install-state authority. A manifest that was READ but cannot be
166
+ // honored (newer layoutVersion, structural damage) must refuse the whole
167
+ // resolution — falling through to the legacy extension dir would launch a
168
+ // runtime that does not match the installed protocol. Legacy fallback stays
169
+ // legitimate only when the reader genuinely saw NO manifest (absent file →
170
+ // `options.manifest === undefined`), i.e. a real legacy-only install, or
171
+ // when there is no canonical runtime to bypass.
172
+ if (options.canonicalRuntimeExists && options.manifest !== undefined && parsed.manifest === undefined) {
173
+ return {
174
+ mode: 'missing',
175
+ paths,
176
+ reason: parsed.error ?? 'install_manifest_malformed',
177
+ nextAction: parsed.error?.startsWith('install_manifest_unsupported_version')
178
+ ? 'Update PD Companion from https://github.com/csuzngjh/principles/releases (companion-v* Windows installer); do not reinstall the healthy PD runtime.'
179
+ : 'Run the PD installer/updater to repair the structurally damaged installation manifest.',
180
+ ...(warnings !== undefined ? { warnings } : {}),
181
+ };
101
182
  }
102
183
  if (options.legacyExtensionExists) {
103
184
  return {
@@ -106,6 +187,7 @@ export function resolveInstallLayout(options) {
106
187
  ...(parsed.manifest ? { manifest: parsed.manifest } : {}),
107
188
  reason: parsed.error ?? 'install_layout_legacy_fallback',
108
189
  nextAction: 'Run the installer update to migrate the shared runtime to ~/.pd/runtime.',
190
+ ...(warnings !== undefined ? { warnings } : {}),
109
191
  };
110
192
  }
111
193
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/install-layout",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",