@volter/live 0.5.210 → 0.5.212

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 (67) hide show
  1. package/BUNDLED_NOTICES +13 -13
  2. package/README.md +7 -11
  3. package/dist/chunk-7SZ3C4CC.js +768 -0
  4. package/dist/chunk-7SZ3C4CC.js.map +7 -0
  5. package/dist/chunk-7WKVJGHZ.js +15 -0
  6. package/dist/chunk-7WKVJGHZ.js.map +7 -0
  7. package/dist/chunk-SDIWRX5X.js +189 -0
  8. package/dist/chunk-SDIWRX5X.js.map +7 -0
  9. package/dist/chunk-USXOIBVX.js +81 -0
  10. package/dist/chunk-USXOIBVX.js.map +7 -0
  11. package/dist/chunk-Y5W77HYT.js +123 -0
  12. package/dist/chunk-Y5W77HYT.js.map +7 -0
  13. package/dist/chunk-YPAOTPIF.js +202 -0
  14. package/dist/chunk-YPAOTPIF.js.map +7 -0
  15. package/dist/client.d.ts +370 -0
  16. package/dist/client.js +9 -0
  17. package/dist/client.js.map +7 -0
  18. package/dist/discovery.d.ts +32 -0
  19. package/dist/discovery.js +19 -0
  20. package/dist/discovery.js.map +7 -0
  21. package/dist/editor-document.d.ts +12 -47
  22. package/dist/editor.d.ts +34 -181
  23. package/dist/http-transport.d.ts +6 -0
  24. package/dist/index.d.ts +1 -5
  25. package/dist/index.js +76 -1722
  26. package/dist/index.js.map +4 -4
  27. package/dist/installed-package.d.ts +6 -0
  28. package/dist/installed-package.js +7 -0
  29. package/dist/installed-package.js.map +7 -0
  30. package/dist/lazy-proxy.d.ts +11 -11
  31. package/dist/member-hints.d.ts +6 -9
  32. package/dist/product-create.d.ts +29 -0
  33. package/dist/product-create.js +13 -0
  34. package/dist/product-create.js.map +7 -0
  35. package/dist/product-locator.d.ts +100 -0
  36. package/dist/product-locator.js +25 -0
  37. package/dist/product-locator.js.map +7 -0
  38. package/dist/registry-format.d.ts +44 -0
  39. package/dist/registry-format.js +21 -0
  40. package/dist/registry-format.js.map +7 -0
  41. package/dist/session.d.ts +10 -3
  42. package/dist/singleton.d.ts +1 -13
  43. package/dist/volter-manifest.d.ts +55 -0
  44. package/dist/volter-manifest.js +9 -0
  45. package/dist/volter-manifest.js.map +7 -0
  46. package/dist/workbench-locator.d.ts +84 -0
  47. package/dist/workbench-locator.js +416 -0
  48. package/dist/workbench-locator.js.map +7 -0
  49. package/package.json +35 -3
  50. package/src/client.ts +1052 -0
  51. package/src/discovery.ts +105 -0
  52. package/src/editor-document.ts +12 -49
  53. package/src/editor.ts +45 -212
  54. package/src/http-transport.ts +19 -0
  55. package/src/index.ts +3 -7
  56. package/src/installed-package.ts +15 -0
  57. package/src/lazy-proxy.ts +11 -11
  58. package/src/member-hints.ts +8 -12
  59. package/src/product-create.ts +38 -0
  60. package/src/product-locator.ts +327 -0
  61. package/src/registry-format.ts +185 -0
  62. package/src/session.ts +38 -66
  63. package/src/singleton.ts +1 -16
  64. package/src/volter-manifest.ts +97 -0
  65. package/src/workbench-locator.ts +625 -0
  66. package/dist/tools.d.ts +0 -23
  67. package/src/tools.ts +0 -45
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Shared editor-session registry format: entry shape, guards, path,
3
+ * liveness-filtered reads, served-project parsing and pending launches.
4
+ * The server’s session-registry module owns writes to this format.
5
+ */
6
+
7
+ import {
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join, resolve } from 'node:path';
17
+ import { setTimeout as delay } from 'node:timers/promises';
18
+
19
+ /** One registry entry, exactly as the server's write half records it. */
20
+ export interface EditorSessionEntry {
21
+ /** Canonical project root currently open, or null when none. */
22
+ project: string | null;
23
+ port: number;
24
+ /** PID of the dev-server process. */
25
+ pid: number;
26
+ /** ISO timestamp of server start. */
27
+ startedAt: string;
28
+ sessionId: string | null;
29
+ /** Private loopback control credential — never returned by an editor HTTP
30
+ * route; exists only in this user-private registry. */
31
+ controlSecret?: string | null;
32
+ repositoryId: string | null;
33
+ worktreeId: string | null;
34
+ worktreeRoot: string | null;
35
+ projectRelativePath: string | null;
36
+ branch: string | null;
37
+ headCommit: string | null;
38
+ baseCommit: string | null;
39
+ }
40
+
41
+ export const EDITOR_SESSIONS_REGISTRY_FILE = join(homedir(), '.volter', 'editor-sessions.json');
42
+
43
+ export function isEditorSessionEntry(v: unknown): v is EditorSessionEntry {
44
+ if (typeof v !== 'object' || v === null) return false;
45
+ const s = v as Record<string, unknown>;
46
+ const optionalIdentity = (value: unknown) =>
47
+ value === undefined || value === null || typeof value === 'string';
48
+ return (
49
+ (typeof s['project'] === 'string' || s['project'] === null) &&
50
+ typeof s['port'] === 'number' &&
51
+ typeof s['pid'] === 'number' &&
52
+ typeof s['startedAt'] === 'string' &&
53
+ optionalIdentity(s['sessionId']) &&
54
+ optionalIdentity(s['controlSecret']) &&
55
+ optionalIdentity(s['repositoryId']) &&
56
+ optionalIdentity(s['worktreeId']) &&
57
+ optionalIdentity(s['worktreeRoot']) &&
58
+ optionalIdentity(s['projectRelativePath']) &&
59
+ optionalIdentity(s['branch']) &&
60
+ optionalIdentity(s['headCommit']) &&
61
+ optionalIdentity(s['baseCommit'])
62
+ );
63
+ }
64
+
65
+ export function normalizeEditorSessionEntry(session: EditorSessionEntry): EditorSessionEntry {
66
+ return {
67
+ ...session,
68
+ sessionId: session.sessionId ?? null,
69
+ controlSecret: session.controlSecret ?? null,
70
+ repositoryId: session.repositoryId ?? null,
71
+ worktreeId: session.worktreeId ?? null,
72
+ worktreeRoot: session.worktreeRoot ?? null,
73
+ projectRelativePath: session.projectRelativePath ?? null,
74
+ branch: session.branch ?? null,
75
+ headCommit: session.headCommit ?? null,
76
+ baseCommit: session.baseCommit ?? null,
77
+ };
78
+ }
79
+
80
+ /** Whether pid exists. Signal 0's EPERM means it exists but this caller cannot signal it. */
81
+ export function pidAlive(pid: number): boolean {
82
+ try {
83
+ process.kill(pid, 0);
84
+ return true;
85
+ } catch (error) {
86
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
87
+ }
88
+ }
89
+
90
+ /** The registry's live entries — shape-validated and PID-liveness-filtered. */
91
+ export function readLiveRegisteredSessions(): EditorSessionEntry[] {
92
+ try {
93
+ const raw: unknown = JSON.parse(readFileSync(EDITOR_SESSIONS_REGISTRY_FILE, 'utf8'));
94
+ return Array.isArray(raw)
95
+ ? raw
96
+ .filter(isEditorSessionEntry)
97
+ .map(normalizeEditorSessionEntry)
98
+ .filter((s) => pidAlive(s.pid))
99
+ : [];
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ /** Announce a launch before the server can register. Each launcher owns its file. */
106
+ export function announceEditorLaunch(project: string): () => void {
107
+ const directory = join(project, '.volter', 'editor-launches');
108
+ mkdirSync(directory, { recursive: true });
109
+ const file = join(directory, `${process.pid}.json`);
110
+ writeFileSync(file, JSON.stringify({ pid: process.pid }));
111
+ const clear = () => {
112
+ process.removeListener('exit', clear);
113
+ try {
114
+ unlinkSync(file);
115
+ } catch {
116
+ // Already cleared when the server became ready.
117
+ }
118
+ };
119
+ process.once('exit', clear);
120
+ return clear;
121
+ }
122
+
123
+ function hasPendingEditorLaunch(project: string): boolean {
124
+ const directory = join(project, '.volter', 'editor-launches');
125
+ try {
126
+ return readdirSync(directory).some((name) => {
127
+ if (!/^\d+\.json$/.test(name)) return false;
128
+ const pid = Number(name.slice(0, -5));
129
+ return pid !== process.pid && pidAlive(pid);
130
+ });
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /** Wait only for an observed live launcher, never for a missing editor. */
137
+ export async function waitForPendingEditorLaunch(project: string): Promise<void> {
138
+ const canon = canonicalizeProjectPath(project);
139
+ const ready = () =>
140
+ readLiveRegisteredSessions().some(
141
+ (session) => session.project !== null && canonicalizeProjectPath(session.project) === canon,
142
+ );
143
+ if (ready() || !hasPendingEditorLaunch(canon)) return;
144
+ const deadline = Date.now() + 180_000;
145
+ while (!ready()) {
146
+ if (!hasPendingEditorLaunch(canon)) {
147
+ // The launcher clears its announcement only after registration.
148
+ if (ready()) return;
149
+ throw new Error(`The editor launch for ${canon} ended before registering a session.`);
150
+ }
151
+ if (Date.now() >= deadline) {
152
+ throw new Error(
153
+ `The editor launch for ${canon} is still running but has not registered within 180s.`,
154
+ );
155
+ }
156
+ await delay(100);
157
+ }
158
+ }
159
+
160
+ /** Read the project identity reported by /__editor/project. A serving path
161
+ * identifies an open project even when its manifest cannot be parsed or
162
+ * validated; project: null alone denotes no described project. */
163
+ export function servedProjectAnswer(body: unknown): {
164
+ path: string | null;
165
+ manifestError: string | null;
166
+ } {
167
+ const b = body as {
168
+ project?: { path?: string } | null;
169
+ serving?: { path?: string; error?: string } | null;
170
+ };
171
+ return {
172
+ path: b.project?.path ?? b.serving?.path ?? null,
173
+ manifestError: b.project ? null : (b.serving?.error ?? null),
174
+ };
175
+ }
176
+
177
+ /** Canonical (realpathed when possible) absolute form of a project path. */
178
+ function canonicalizeProjectPath(p: string): string {
179
+ const absolute = resolve(p);
180
+ try {
181
+ return realpathSync(absolute);
182
+ } catch {
183
+ return absolute;
184
+ }
185
+ }
package/src/session.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  /** Resolve an existing session for exactly one project.
2
2
  * Prefer the project's hint, verify it against the served canonical path,
3
- * then consult the shared registry. Never fall back to another project.
4
- * Storage identifiers move with the server in the coordinated migration. */
3
+ * then consult the shared registry. Never fall back to another project. */
5
4
 
6
5
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
7
6
  import { dirname, join, resolve } from 'node:path';
8
7
  import { resolveManifestPath } from '@volter/project/manifest/locate';
9
- import { resolveProductForProject } from '@volter/sdk/session/product-locator';
8
+ import { resolveProductForProject } from './product-locator.js';
10
9
  import {
11
10
  EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
12
11
  type EditorSessionInfo,
@@ -14,7 +13,7 @@ import {
14
13
  HttpSessionDiscovery,
15
14
  servedProjectAnswer,
16
15
  withTimeout,
17
- } from '@volter/sdk/session/discovery';
16
+ } from './discovery.js';
18
17
 
19
18
  /** Nearest ancestor containing the project's canonical manifest. */
20
19
  export function findProjectRootFrom(dir: string): string | null {
@@ -78,6 +77,8 @@ function sameDirectory(a: string, b: string): boolean {
78
77
  /** The code a refusal carries when the registry was read and no session opens the project: the
79
78
  * one state in which "no editor is open here" is a fact, which `close` answers as done. */
80
79
  export const NO_SESSION_FOR_PROJECT = 'VOLTER_NO_SESSION_FOR_PROJECT';
80
+ /** The `code` of the refusal for this project's session whose manifest does not load; it carries `session`. */
81
+ export const SESSION_MANIFEST_UNREADABLE = 'VOLTER_SESSION_MANIFEST_UNREADABLE';
81
82
 
82
83
  function readProjectSession(projectRoot: string): ProjectSessionHint | null {
83
84
  try {
@@ -103,24 +104,10 @@ function readProjectSession(projectRoot: string): ProjectSessionHint | null {
103
104
  }
104
105
 
105
106
  /**
106
- * WHICH PROJECT a `/__editor/project` body says its server is serving, and the
107
- * reason it cannot describe it.
108
- *
109
- * `serving` is the server's own statement of "I AM serving this project, I
110
- * just cannot describe it" — its manifest is unparseable or fails strict
111
- * validation. The route added it because a bare `{ project: null }` there is
112
- * indistinguishable from "no project open"; reading only `project.path`
113
- * reproduces that collapse on this side, and the cost is the worst kind of
114
- * wrong answer: this door refused a session that WAS open on the caller's
115
- * project with "no live editor session found … (N other live session(s) found,
116
- * but none open this project)" — sending the operator to start an editor that
117
- * was already running, with the real defect (their own manifest) never named.
118
- */
119
-
120
- /**
121
- * What the server on `port` says it serves — `undefined` when it did not
122
- * answer at all. One probe: the manifest failure arrives with the path, so
123
- * neither the hint path nor the refusal path pays a second round trip.
107
+ * Read project identity and manifest failure in one `/__editor/project` probe.
108
+ * A serving path identifies the project even when its manifest is unreadable;
109
+ * `project: null` without that path denotes no described project. Return
110
+ * `undefined` when the server does not answer successfully.
124
111
  */
125
112
  async function probeServedProject(
126
113
  port: number,
@@ -141,46 +128,26 @@ async function probeServedProject(
141
128
  * because the project's manifest does not load.
142
129
  *
143
130
  * A refusal, not an attach: with no readable manifest the editor page itself
144
- * renders its startup-error screen, so there is no editor and no game behind
145
- * this door to bind `{ editor, game, page, tools, session }` to. What changed
146
- * is only that it now says the true thing — the failing file and key — instead
147
- * of "no live editor session found", which sent operators to start an editor
148
- * that was already running while their actual defect went unnamed.
131
+ * renders its startup-error screen, so there is no editor behind this door to
132
+ * bind `{ editor, session }` to. It names the failing file and key, so an
133
+ * operator fixes the manifest rather than starting a second editor.
149
134
  */
150
- function manifestRefusal(projectRoot: string, manifestError: string): Error {
151
- return new Error(
152
- `@volter/live: the editor session for ${projectRoot} is live, but its volter.project.json does ` +
153
- 'not load, so there is no editor or game to drive — the editor page is showing this same ' +
154
- `error. Fix the manifest and retry; the session recovers on save, no restart needed.\n${manifestError}`,
135
+ function manifestRefusal(projectRoot: string, manifestError: string, port: number): Error {
136
+ return Object.assign(
137
+ new Error(
138
+ `@volter/live: the editor session for ${projectRoot} is live, but its volter.project.json does ` +
139
+ 'not load, so there is no editor or project to drive — the editor page is showing this same ' +
140
+ `error. Fix the manifest and retry; the session recovers on save, no restart needed.\n${manifestError}`,
141
+ ),
142
+ // The session is still this project's, so a caller that only needs to END it (`close`) can.
143
+ { code: SESSION_MANIFEST_UNREADABLE, session: { port, projectRoot } },
155
144
  );
156
145
  }
157
146
 
158
147
  /**
159
- * Resolve `projectDir` (default `process.cwd()`) to the port of its already-
160
- * running the editor's `edit` command session. Throws a descriptive error (never hangs
161
- * indefinitely — bounded by `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`, and never
162
- * silently attaches to an unrelated project's session — see the module doc
163
- * above) when no volter.project.json is found, or no live session covers it.
164
- */
165
- /**
166
- * THE REFUSAL WHEN NOTHING MATCHED — and it says WHICH nothing.
167
- *
168
- * "No live editor session found … run the editor's `edit` command" used to be the answer to
169
- * four different states, only one of which it described. The other three sent
170
- * the operator to start an editor that was already running:
171
- *
172
- * - discovery FAILED (a probe timeout under load) — nothing was learned, so
173
- * "no session is running" is not a fact anyone established;
174
- * - the registry was read and is genuinely empty — the one case the old text
175
- * was right about;
176
- * - sessions exist, but every one resolves to a different canonical path. In
177
- * a repo worked through git worktrees this is the ORDINARY miss: two
178
- * checkouts of the same project differ only in a path prefix, and a
179
- * symlinked worktree's `realpath` diverges from the path the caller typed.
180
- * Naming both sides is what makes it a two-second diagnosis instead of a
181
- * hunt.
182
- *
183
- * Each branch prescribes only what its own state supports.
148
+ * Distinguish failed discovery, a readable empty registry and sessions serving
149
+ * other canonical paths. Include the requested and listed paths for a path
150
+ * mismatch; only a confirmed absence carries `NO_SESSION_FOR_PROJECT`.
184
151
  */
185
152
  function noMatchingSessionRefusal(
186
153
  projectRoot: string,
@@ -192,8 +159,10 @@ function noMatchingSessionRefusal(
192
159
  return new Error(
193
160
  `@volter/live: could not complete editor session discovery while looking for ${projectRoot} — ` +
194
161
  `${discoveryFailure}. This is not the answer "no editor is running": the question went ` +
195
- 'unanswered, so nothing is known about what is live. Retry (a probe can time out while ' +
196
- `the box is loaded); if it keeps failing, ${sessionsCommandFor(projectRoot)} asks the same question directly.`,
162
+ 'unanswered, so nothing is known about what is live. The step named above is the one that ' +
163
+ 'did not answer: that session\'s server holds its port but did not reply to the request. ' +
164
+ 'Its own log (logs/editor-<port>.log in the project it serves) says how far its start got; ' +
165
+ `${sessionsCommandFor(projectRoot)} asks the same question directly.`,
197
166
  );
198
167
  }
199
168
  if (sessions.length === 0) {
@@ -221,6 +190,12 @@ function noMatchingSessionRefusal(
221
190
  ), thisFolderElsewhere ? {} : { code: NO_SESSION_FOR_PROJECT });
222
191
  }
223
192
 
193
+ /**
194
+ * Resolve `projectDir` (default `process.cwd()`) to its already-running editor
195
+ * session. Verify the project hint, then consult the shared registry, with
196
+ * each discovery probe bounded by `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`.
197
+ * Throw when no project manifest or matching live session is found.
198
+ */
224
199
  export async function resolveSession(
225
200
  projectDir: string = process.cwd(),
226
201
  deps: SessionResolutionDeps = {},
@@ -253,18 +228,15 @@ export async function resolveSession(
253
228
  } else {
254
229
  const served = await probeServedProject(localHint.port);
255
230
  if (served?.path != null && canonicalPath(served.path) === canonicalPath(projectRoot)) {
256
- if (served.manifestError !== null) throw manifestRefusal(projectRoot, served.manifestError);
231
+ if (served.manifestError !== null) throw manifestRefusal(projectRoot, served.manifestError, localHint.port);
257
232
  return { port: localHint.port, projectRoot };
258
233
  }
259
234
  }
260
235
  }
261
236
 
262
237
  let sessions: EditorSessionInfo[];
263
- // A FAILED discovery is not an empty one. Collapsing the two into `[]` is
264
- // what made this door answer "no live editor session found" — and prescribe
265
- // the editor's `edit` command — for a probe that merely timed out under load, sending the
266
- // operator to start an editor that was already running while the real defect
267
- // went unnamed. The same collapse the manifest refusal above was added for.
238
+ // Retain discovery failures separately from an empty session list so the
239
+ // refusal names an unanswered probe rather than claiming no session exists.
268
240
  let discoveryFailure: string | null = null;
269
241
  try {
270
242
  sessions = await withTimeout(
@@ -281,7 +253,7 @@ export async function resolveSession(
281
253
  const match = sessions.find((s) => s.project !== null && canonicalPath(s.project) === canon);
282
254
  if (!match) throw noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure);
283
255
 
284
- if (match.manifestError != null) throw manifestRefusal(projectRoot, match.manifestError);
256
+ if (match.manifestError != null) throw manifestRefusal(projectRoot, match.manifestError, match.port);
285
257
 
286
258
  return { port: match.port, projectRoot };
287
259
  }
package/src/singleton.ts CHANGED
@@ -1,19 +1,7 @@
1
- /**
2
- * A tiny memoized-async-factory primitive — the machinery behind
3
- * `index.ts`'s lazy top-level `editor`/`tools` singletons. Pulled into
4
- * its own module (rather than inlined) so it's independently unit-testable
5
- * with a fake factory, with no need to exercise a real `connect()` (session
6
- * discovery, network) just to prove "two accesses, one connect".
7
- *
8
- * Caches the in-flight PROMISE, not just the resolved value — two callers
9
- * racing `ensure()` before the first resolves still share the SAME
10
- * connection attempt, not two independent ones.
11
- */
1
+ /** Memoized async connection. Concurrent ensure calls share the same in-flight promise. */
12
2
  export interface LazySession<T> {
13
3
  /** Returns the memoized promise, creating it via `factory()` on first call. */
14
4
  ensure(): Promise<T>;
15
- /** Clears the memo — the NEXT `ensure()` calls `factory()` again. Exposed for tests (and for a caller that deliberately wants to reconnect); not needed in ordinary use. */
16
- reset(): void;
17
5
  }
18
6
 
19
7
  export function createLazySession<T>(factory: () => Promise<T>): LazySession<T> {
@@ -23,8 +11,5 @@ export function createLazySession<T>(factory: () => Promise<T>): LazySession<T>
23
11
  promise ??= factory();
24
12
  return promise;
25
13
  },
26
- reset(): void {
27
- promise = null;
28
- },
29
14
  };
30
15
  }
@@ -0,0 +1,97 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { readPageEntry, readVolterContributions } from '@volter/sdk/volter-contributions';
4
+
5
+ /**
6
+ * THE NODE READER of a package's `package.json` as Volter reads it: its name, its dependency
7
+ * names and its `contributes.volter` declarations, which `@volter/sdk/volter-contributions` reads as the workbench and
8
+ * the page read them. The build's product plugin, the session's catalog and
9
+ * serving doors, the shared-React scope and the pre-bundle crawl all read a package here, so a
10
+ * declaration means one thing everywhere it is read.
11
+ *
12
+ * It parses and does no policy. What an unusable declaration costs is the caller's to say: a
13
+ * product build refuses it, the catalog reports it as a load error, a scope decision treats it
14
+ * as no declaration. {@link declaredContributions} is the one test of "this package contributes".
15
+ */
16
+ export interface VolterManifest {
17
+ /** The `package.json`, absolute. */
18
+ readonly path: string;
19
+ /** Its package's directory. */
20
+ readonly dir: string;
21
+ readonly name: string | null;
22
+ readonly version: string | null;
23
+ readonly private: boolean;
24
+ readonly bin: unknown;
25
+ /** Every name in `dependencies`, `devDependencies` and `optionalDependencies`, in that order,
26
+ * each once. */
27
+ readonly dependencyNames: readonly string[];
28
+ /** `package.json#contributes.volter`, unchecked: the package's contribution modules, an object
29
+ * keyed by kind, and its declarations (`serving`, `viewRoutes`, `product`). It is where Code-OSS's extension
30
+ * service reads them too, through the kit's `volter` extension point. */
31
+ readonly contributions: unknown;
32
+ /** `package.json#contributes.languageModelTools`, unchecked: the agent tools Code-OSS registers from it. */
33
+ readonly languageModelTools: unknown;
34
+ /** `package.json#contributes.volter.product`, a product's declaration, read and checked by `product-locator.ts`;
35
+ * `undefined` when the package declares none or it is not an object. */
36
+ readonly product: ProductDeclaration | undefined;
37
+ }
38
+
39
+ /** `package.json#contributes.volter.product`, as declared and unchecked. */
40
+ export interface ProductDeclaration {
41
+ readonly entry?: unknown;
42
+ readonly composes?: unknown;
43
+ readonly colorTheme?: unknown;
44
+ readonly workbench?: unknown;
45
+ readonly displayName?: unknown;
46
+ readonly install?: unknown;
47
+ readonly upgrade?: unknown;
48
+ readonly adapterRequired?: unknown;
49
+ }
50
+
51
+ /** `path`'s manifest, or `null` when there is no file there or it is not a JSON object. */
52
+ export function readVolterManifest(path: string): VolterManifest | null {
53
+ let json: unknown;
54
+ try {
55
+ json = JSON.parse(readFileSync(path, 'utf8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) return null;
60
+ const record = json as Record<string, unknown>;
61
+ const names = (key: string): string[] => {
62
+ const section = record[key];
63
+ return section !== null && typeof section === 'object' ? Object.keys(section) : [];
64
+ };
65
+ const contributes = record['contributes'];
66
+ const contributions = contributes !== null && typeof contributes === 'object' ? (contributes as Record<string, unknown>)['volter'] : undefined;
67
+ const declared = contributions !== null && typeof contributions === 'object' ? (contributions as Record<string, unknown>) : {};
68
+ const product = declared['product'];
69
+ return {
70
+ path,
71
+ dir: dirname(path),
72
+ name: typeof record['name'] === 'string' ? record['name'] : null,
73
+ version: typeof record['version'] === 'string' ? record['version'] : null,
74
+ private: record['private'] === true,
75
+ bin: record['bin'],
76
+ dependencyNames: [...new Set([...names('dependencies'), ...names('devDependencies'), ...names('optionalDependencies')])],
77
+ contributions,
78
+ languageModelTools: contributes !== null && typeof contributes === 'object' ? (contributes as Record<string, unknown>)['languageModelTools'] : undefined,
79
+ product: product !== null && typeof product === 'object' && !Array.isArray(product) ? (product as ProductDeclaration) : undefined,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * The package's declared modules, each contribution's and its page half's, with why any entry is refused; `null` when
85
+ * it declares none (an absent `contributes.volter`, or one that is not an object). A package with the object, even an
86
+ * empty one, is a contributing package: its modules are editor-tree code.
87
+ */
88
+ export function declaredContributions(manifest: VolterManifest | null): { readonly modules: readonly string[]; readonly errors: readonly string[] } | null {
89
+ const declared = manifest?.contributions;
90
+ if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) return null;
91
+ const { entries, errors } = readVolterContributions(declared);
92
+ const page = readPageEntry(manifest?.name ?? manifest?.path ?? '', (declared as Record<string, unknown>)['page']);
93
+ return {
94
+ modules: [...entries.map(({ entry }) => entry), ...('entry' in page && page.entry !== null ? [page.entry] : [])],
95
+ errors: 'malformed' in page ? [...errors, page.malformed] : errors,
96
+ };
97
+ }