@hydranium/cli 1.0.0-next.309 → 1.0.0-next.312

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.
@@ -265,9 +265,13 @@ function arrayLiteral(elements: readonly string[], indent: string, columns: numb
265
265
  * The FRAMEWORK packages each head adds, carrying no version: every
266
266
  * `@hydranium/*` pin is derived at scaffold time from {@link frameworkPin}, so
267
267
  * none of them can name a version its siblings were not published at.
268
+ *
269
+ * `base` is every head set's: the services every head serves from are composed
270
+ * the same with or without an LSP head.
268
271
  */
269
- const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead, readonly string[]> = {
270
- lsp: ['@hydranium/core', '@hydranium/langium', '@hydranium/protocol'],
272
+ const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead | 'base', readonly string[]> = {
273
+ base: ['@hydranium/core', '@hydranium/langium', '@hydranium/protocol'],
274
+ lsp: [],
271
275
  data: ['@hydranium/data-server'],
272
276
  glsp: ['@hydranium/glsp-server']
273
277
  };
@@ -288,12 +292,13 @@ const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead, readonly string[]> = {
288
292
  * `overrides` block exists to prevent — so a repo gate holds each entry here
289
293
  * against the manifest that declares it.
290
294
  *
291
- * `langium` is required by `lsp` even though hand-written code goes through the
295
+ * `langium` is required even though hand-written code goes through the
292
296
  * `@hydranium/langium` chokepoint, because `langium-cli` emits direct imports
293
- * into the generated files and regenerates them on every build.
297
+ * into the generated files and regenerates them on every build. The `vscode-*`
298
+ * packages are required peers of `@hydranium/core`, so they are `base` too.
294
299
  */
295
- const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, string>>> = {
296
- lsp: {
300
+ const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead | 'base', Readonly<Record<string, string>>> = {
301
+ base: {
297
302
  langium: '4.3.1',
298
303
  // Exact, as are the protocol and transport it pins, so Langium and the
299
304
  // framework share their copies; across copies, an error a handler throws
@@ -302,6 +307,7 @@ const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, st
302
307
  'vscode-jsonrpc': '9.0.0',
303
308
  'vscode-languageserver-protocol': '3.18.1'
304
309
  },
310
+ lsp: {},
305
311
  data: {},
306
312
  glsp: {
307
313
  '@eclipse-glsp/graph': '2.7.0',
@@ -331,8 +337,13 @@ const PROTOCOL_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string
331
337
  * package shares with the framework.
332
338
  */
333
339
  export function workspaceRootPins(heads: readonly InitHead[]): ReadonlyArray<readonly [string, string]> {
334
- return heads
335
- .flatMap(head => [...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES[head]), ...Object.entries(PROTOCOL_THIRD_PARTY_DEPENDENCIES[head])])
340
+ return [
341
+ ...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES.base),
342
+ ...heads.flatMap(head => [
343
+ ...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES[head]),
344
+ ...Object.entries(PROTOCOL_THIRD_PARTY_DEPENDENCIES[head])
345
+ ])
346
+ ]
336
347
  .filter(([, version]) => /^\d+\.\d+\.\d+$/.test(version))
337
348
  .sort(([left], [right]) => left.localeCompare(right));
338
349
  }
@@ -371,10 +382,10 @@ function sortedBlock(dependencies: Readonly<Record<string, string>>, indent: str
371
382
  .join(',\n');
372
383
  }
373
384
 
374
- /** The server's dependency block: the head set's packages and the protocol member. */
385
+ /** The server's dependency block: the base and head set's packages and the protocol member. */
375
386
  function dependencyBlock(composition: InitComposition, indent: string): string {
376
387
  const merged: Record<string, string> = { [memberPackageName(composition, 'protocol')]: MEMBER_PIN };
377
- for (const head of composition.heads) {
388
+ for (const head of ['base', ...composition.heads] as const) {
378
389
  Object.assign(merged, HEAD_THIRD_PARTY_DEPENDENCIES[head]);
379
390
  for (const framework of HEAD_FRAMEWORK_DEPENDENCIES[head]) {
380
391
  merged[framework] = frameworkPin(composition);
@@ -455,7 +466,7 @@ __BIN__ },
455
466
  "generate:transfer-model": "hydranium-cli generate-transfer-model --ast-file src/language-server/generated/ast.ts --augmentation-file src/language-server/ast.ts --out-file ../protocol/src/generated-hydranium/transfer-model.ts --ast-builder-file src/language-server/generated-hydranium/ast-builder.ts --languages-file ../protocol/src/generated-hydranium/languages.ts --element-type-name __NAME__Element --terminals-name __NAME__Terminals --regen-command \\"Run: __NPM_RUN__ generate:transfer-model\\"",
456
467
  "langium:generate": "langium generate",
457
468
  "langium:watch": "langium generate --watch",
458
- __LINT__ "start": "node lib/main.js --stdio",
469
+ __LINT__ "start": "node lib/main.js__START_ARGS__",
459
470
  "test": "npm run typecheck:test && vitest run",
460
471
  "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
461
472
  "watch": "tsc -b --watch"
@@ -540,6 +551,7 @@ function packageJson(composition: InitComposition): string {
540
551
  .replace('__KEYWORDS__', keywords)
541
552
  .replace('__BIN__', binBlock(composition))
542
553
  .replace('__LINT__', lintScript(forMember(composition, 'server'), 'src test'))
554
+ .replace('__START_ARGS__', composition.heads.includes('lsp') ? ' --stdio' : ' ../workspace')
543
555
  .replace('__VITEST__', pinsVitest(composition) ? '~4.0.18' : '^4.0.0')
544
556
  .replace('__PRIVATE__', composition.packaging.private ? ' "private": true,\n' : '')
545
557
  .replace('__NPM_RUN__', npmRun(composition, 'server'))
@@ -896,55 +908,75 @@ ${returnType}
896
908
  return project(text, composition);
897
909
  }
898
910
 
911
+ /** One exported constant of the protocol member's `head-ports.ts`. */
912
+ interface HeadPortConstant {
913
+ readonly name: string;
914
+ readonly value: string;
915
+ readonly doc: string;
916
+ }
917
+
899
918
  /**
900
- * The exported constant names for the head set, in emission order. Both are
901
- * keyed by the PROJECT rather than by a language: one server of each kind per
919
+ * How a host finds each socket head's port, in emission order: an LSP request
920
+ * when the LSP head publishes the ports, else the environment variable
921
+ * `main.ts` reads a port from.
922
+ *
923
+ * Keyed by the PROJECT rather than by a language: one server of each kind per
902
924
  * process serves every registered grammar, so a language-derived name would tie
903
925
  * a project-level endpoint to whichever grammar was scaffolded first.
904
926
  */
905
- function portCommandNames(composition: InitComposition): string[] {
927
+ function headPortConstants(composition: InitComposition): HeadPortConstant[] {
906
928
  const upper = screamingSnake(composition.projectId);
907
- return [
908
- ...(composition.heads.includes('data') ? [`${upper}_DATA_SERVER_PORT_COMMAND`] : []),
909
- ...(composition.heads.includes('glsp') ? [`${upper}_GLSP_PORT_COMMAND`] : [])
929
+ const lsp = composition.heads.includes('lsp');
930
+ const heads = [
931
+ ...(composition.heads.includes('data') ? [{ key: 'DATA_SERVER', path: 'data-server', label: 'data-server' }] : []),
932
+ ...(composition.heads.includes('glsp') ? [{ key: 'GLSP_SERVER', path: 'glsp-server', label: 'GLSP' }] : [])
910
933
  ];
934
+ return heads.map(head =>
935
+ lsp
936
+ ? {
937
+ name: `${upper}_${head.key}_PORT_COMMAND`,
938
+ value: `${composition.projectId}/${head.path}/port`,
939
+ doc: `LSP request the host queries to discover the ${head.label} socket port.`
940
+ }
941
+ : {
942
+ name: `${upper}_${head.key}_PORT_ENV`,
943
+ value: `${upper}_${head.key}_PORT`,
944
+ doc: `Environment variable naming the ${head.label} socket port when no \`--${head.path}-port\` flag does.`
945
+ }
946
+ );
911
947
  }
912
948
 
913
949
  /**
914
- * The socket heads' discovery commands, in the protocol member.
950
+ * The socket heads' port commands or variables, in the protocol member.
915
951
  *
916
- * A host shell has to name the same string the server publishes, and reaches
917
- * it here without depending on the server. A host that retypes the literal
952
+ * A host shell has to name the same string the server reads, and reaches it
953
+ * here without depending on the server. A host that retypes the literal
918
954
  * instead gets no error when it drifts: the framework's port poll defaults to
919
955
  * `findPortAttempts = -1`, so a wrong command retries forever rather than
920
- * failing.
956
+ * failing, and a wrong variable is never read.
921
957
  */
922
958
  function headPortsFile(composition: InitComposition): string {
923
- const upper = screamingSnake(composition.projectId);
924
- const data = composition.heads.includes('data');
925
- const glsp = composition.heads.includes('glsp');
926
- const blocks = [
927
- ...(data
928
- ? [
929
- `/** LSP request the host queries to discover the data-server socket port. */\nexport const ${upper}_DATA_SERVER_PORT_COMMAND = '${composition.projectId}/data-server/port';`
930
- ]
931
- : []),
932
- ...(glsp
933
- ? [
934
- `/** LSP request the host queries to discover the GLSP socket port. */\nexport const ${upper}_GLSP_PORT_COMMAND = '${composition.projectId}/glsp/port';`
935
- ]
936
- : [])
937
- ];
938
- return `// The LSP requests a host queries to discover this server's socket head${blocks.length > 1 ? 's' : ''}.
959
+ const constants = headPortConstants(composition);
960
+ const blocks = constants.map(constant => `/** ${constant.doc} */\nexport const ${constant.name} = '${constant.value}';`);
961
+ const heads = `socket head${blocks.length > 1 ? 's' : ''}`;
962
+ const opening = composition.heads.includes('lsp')
963
+ ? `// The LSP requests a host queries to discover this server's ${heads}.`
964
+ : `// The environment variables that name the ports of this server's ${heads}.`;
965
+ const usage = composition.heads.includes('lsp')
966
+ ? `// The server publishes them and a host shell has to name the same string to
967
+ // reach the head; both import them from here. A shell that retypes the literal
968
+ // gets no error when it drifts: the framework's port poll retries indefinitely
969
+ // by default.`
970
+ : `// The server reads them and a process that starts it sets them; both import
971
+ // them from here. A variable retyped with a typo is never read, and the head
972
+ // then listens on whichever port is free.`;
973
+ return `${opening}
939
974
  //
940
- // Both are keyed by the PROJECT, not the language: one server of each kind per
975
+ // ${blocks.length > 1 ? 'Both are' : 'It is'} keyed by the PROJECT, not the language: one server of each kind per
941
976
  // process serves every registered grammar, so a language-derived name would tie
942
977
  // a project-level endpoint to whichever grammar was scaffolded first.
943
978
  //
944
- // The server publishes them and a host shell has to name the same string to
945
- // reach the head; both import them from here. A shell that retypes the literal
946
- // gets no error when it drifts: the framework's port poll retries indefinitely
947
- // by default.
979
+ ${usage}
948
980
 
949
981
  ${blocks.join('\n\n')}
950
982
  `;
@@ -999,10 +1031,12 @@ function mainFile(composition: InitComposition): string {
999
1031
  const columns = columnsFor(composition);
1000
1032
  const roots = [...composition.grammars.map(grammar => grammar.entryRule)].sort();
1001
1033
  const upper = screamingSnake(composition.projectId);
1034
+ const lsp = composition.heads.includes('lsp');
1002
1035
  const data = composition.heads.includes('data');
1003
1036
  const glsp = composition.heads.includes('glsp');
1004
1037
  const diagrams = composition.grammars.filter(grammar => grammar.diagram);
1005
1038
  const plural = roots.length > 1 ? 's' : '';
1039
+ const portFlags = [...(data ? ['--data-server-port'] : []), ...(glsp ? ['--glsp-server-port'] : [])];
1006
1040
 
1007
1041
  const headNames = composition.heads.map(head =>
1008
1042
  head === 'lsp' ? 'the Langium LSP head' : head === 'data' ? 'a socket data-server (model server) head' : 'a GLSP head'
@@ -1016,7 +1050,7 @@ function mainFile(composition: InitComposition): string {
1016
1050
  // `reflect-metadata` must be imported before anything that reads a
1017
1051
  // decorator's emitted metadata, which inversify does at module scope.
1018
1052
  const reflectImport = glsp ? "import 'reflect-metadata';\n" : '';
1019
- const coreNodeSymbols = ['NodeFileSystem', ...(data ? ['publishPortOnLspConnection', 'startSocketServer'] : [])];
1053
+ const coreNodeSymbols = ['NodeFileSystem', ...(data ? [...(lsp ? ['publishPortOnLspConnection'] : []), 'startSocketServer'] : [])];
1020
1054
  const glspImports = glsp
1021
1055
  ? `import { ServerModule } from '@eclipse-glsp/server/node.js';
1022
1056
  import { GlspClientLogger, HydraniumGlspAppModule } from '@hydranium/glsp-server';
@@ -1037,22 +1071,33 @@ ${importList(roots, memberPackageName(composition, 'protocol'), columns, true)}
1037
1071
  `
1038
1072
  : '';
1039
1073
 
1040
- // The commands live in the protocol member, not here: a host shell has to
1041
- // name the same string to reach the head, and this file is an executable
1042
- // entry, so nothing can import from it.
1043
- const portCommands = portCommandNames(composition).length
1044
- ? importList(portCommandNames(composition), memberPackageName(composition, 'protocol'), columns) + '\n'
1045
- : '';
1046
-
1047
- const dataBlock = data
1048
- ? `
1074
+ // The names live in the protocol member, not here: a host shell has to name
1075
+ // the same string to reach the head, and this file is an executable entry,
1076
+ // so nothing can import from it.
1077
+ const portNames = headPortConstants(composition).map(constant => constant.name);
1078
+ const portImports = portNames.length ? importList(portNames, memberPackageName(composition, 'protocol'), columns) + '\n' : '';
1079
+
1080
+ const dataBlock = !data
1081
+ ? ''
1082
+ : !lsp
1083
+ ? `
1084
+ // Data-server head: each accepted client gets its own DataServer.
1085
+ const dataServer = startSocketServer(
1086
+ { shared, workspace, port: portFrom('data-server-port', ${upper}_DATA_SERVER_PORT_ENV), logTag: 'ModelServer', logger: shared.Logger },
1087
+ dataConnection => {
1088
+ new DataServer<${roots.join(' | ')}>(dataConnection, shared);
1089
+ return { dispose: () => undefined };
1090
+ }
1091
+ );
1092
+ `
1093
+ : `
1049
1094
  // Data-server head alongside LSP: binds an ephemeral port, published over the LSP
1050
1095
  // connection for the host to discover. Each accepted client gets its own DataServer.
1051
1096
  //
1052
1097
  // Neither the bind nor the publish may be swallowed: either failure leaves the LSP
1053
1098
  // head serving text edits while every data client waits on a port command that was
1054
1099
  // never registered, and the launcher reports a bind failure only if given a logger.
1055
- const dataServer = startSocketServer({ port: 0, logTag: 'ModelServer', logger: shared.Logger }, dataConnection => {
1100
+ const dataServer = startSocketServer({ shared, port: 0, logTag: 'ModelServer', logger: shared.Logger }, dataConnection => {
1056
1101
  new DataServer<${roots.join(' | ')}>(dataConnection, shared);
1057
1102
  return { dispose: () => undefined };
1058
1103
  });
@@ -1074,8 +1119,7 @@ dataServer.started
1074
1119
  .catch((error: unknown) => {
1075
1120
  shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${describeErrorWithStack(error)}\`);
1076
1121
  });
1077
- `
1078
- : '';
1122
+ `;
1079
1123
 
1080
1124
  const glspBlock = glsp
1081
1125
  ? `
@@ -1084,42 +1128,35 @@ dataServer.started
1084
1128
  // grammar a request concerns, so nothing per-language belongs there — each
1085
1129
  // diagram module declares its own language instead.
1086
1130
  //
1087
- // GLSP framework logs route through the LSP connection rather than stdout, which
1088
- // IS the LSP transport in stdio mode; writing there corrupts the protocol stream.
1131
+ ${
1132
+ lsp
1133
+ ? `// GLSP framework logs route through the LSP connection rather than stdout, which
1134
+ // IS the LSP transport in stdio mode; writing there corrupts the protocol stream.`
1135
+ : `// GLSP framework logs route through the framework's logger to stderr. GLSP's
1136
+ // own startup line goes to stdout, beside the ports line below.`
1137
+ }
1089
1138
  const glspServer = startGlspServer({
1090
1139
  // The GLSP log threshold lives on the logger, not beside it: the framework
1091
1140
  // replaces GLSP's own \`Logger\` binding, so a launcher-level \`logLevel\` would
1092
1141
  // be discarded. The logger tracks the framework's process-wide threshold, so
1093
- // \`HYDRANIUM_LOG_LEVEL\` and the LSP log-level setting govern GLSP output too;
1142
+ // \`HYDRANIUM_LOG_LEVEL\` ${lsp ? 'and the LSP log-level setting govern' : 'governs'} GLSP output too;
1094
1143
  // pass \`logLevel\` only to make GLSP quieter than the rest of the server.
1095
1144
  createLogger: caller => new GlspClientLogger(shared, { component: caller }),
1096
1145
  serverModule: new ServerModule()${diagrams.map(grammar => `.configureDiagramModule(new ${grammar.grammar}DiagramModule())`).join('')},
1097
1146
  appModules: [new HydraniumGlspAppModule({ shared })],
1098
- lspConnection: shared.lsp.Connection,
1099
- portCommand: ${upper}_GLSP_PORT_COMMAND
1147
+ ${
1148
+ lsp
1149
+ ? ` lspConnection: shared.lsp.Connection,
1150
+ portCommand: ${upper}_GLSP_SERVER_PORT_COMMAND`
1151
+ : ` workspace,
1152
+ port: portFrom('glsp-server-port', ${upper}_GLSP_SERVER_PORT_ENV),
1153
+ logger: shared.Logger`
1154
+ }
1100
1155
  });
1101
- void glspServer;
1102
- `
1156
+ ${lsp ? 'void glspServer;\n' : ''}`
1103
1157
  : '';
1104
1158
 
1105
- const text = `${SHEBANG}// Standalone entry point: starts ${headSummary} in the same
1106
- // process, so every head shares one model store.
1107
- // Invocation: \`node lib/main.js --stdio\`, or the package's \`__PROJECT_ID__\` bin
1108
- // script.${
1109
- data
1110
- ? `
1111
- //
1112
- // NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: stdio
1113
- // here carries LSP, and the data head is a socket whose port is published over
1114
- // the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
1115
- : ''
1116
- }
1117
- //
1118
- // Everything here runs at module scope, so this file is an executable rather
1119
- // than a library entry — import \`./index.js\` instead to compose the language.
1120
-
1121
- ${reflectImport}${glspImports}${importList(coreNodeSymbols, '@hydranium/core/node', columns)}
1122
- ${data ? "import { DataServer } from '@hydranium/data-server';\nimport { describeErrorWithStack } from '@hydranium/protocol';\n" : ''}// The framework's entry point, NOT Langium's \`@hydranium/langium/lsp\` one. It is
1159
+ const lspImports = `// The framework's entry point, NOT Langium's \`@hydranium/langium/lsp\` one. It is
1123
1160
  // signature-compatible and delegates straight through; what it adds first is
1124
1161
  // \`assertLspHeadComposed\`, which fails the start if the LSP head's SHARED module
1125
1162
  // was never composed. Reaching for Langium's is the natural mistake and it is
@@ -1127,12 +1164,79 @@ ${data ? "import { DataServer } from '@hydranium/data-server';\nimport { describ
1127
1164
  // didChangeContent debounce and the last-client-close rebuild are all inert.
1128
1165
  import { startLanguageServer, withHydraniumLspFeatures } from '@hydranium/core/lsp';
1129
1166
  import { ProposedFeatures, createConnection } from 'vscode-languageserver/node';
1130
- ${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1131
- ${portCommands === '' ? '' : '\n' + portCommands}
1167
+ `;
1168
+
1169
+ const lspStart = `
1132
1170
  const connection = createConnection(withHydraniumLspFeatures(ProposedFeatures.all));
1133
1171
  const { shared } = create__NAME__Services({ connection, ...NodeFileSystem });
1134
1172
  startLanguageServer(shared);
1135
- ${dataBlock}${glspBlock}`;
1173
+ `;
1174
+
1175
+ // No LSP client names the workspace or asks for the ports here, so the
1176
+ // command line does, and the ports go to stdout once every head listens.
1177
+ const servers = [...(data ? ['dataServer'] : []), ...(glsp ? ['glspServer'] : [])];
1178
+ const socketStart = `
1179
+ const { values, positionals } = parseArgs({
1180
+ options: { ${portFlags.map(flag => `'${flag.slice(2)}': { type: 'string' }`).join(', ')} },
1181
+ allowPositionals: true
1182
+ });
1183
+ // The workspace root: the first argument, else the process cwd.
1184
+ const workspace = positionals[0] ?? process.cwd();
1185
+
1186
+ /** A head's port: its flag, else its environment variable, else 0 for any free port. */
1187
+ function portFrom(flag: keyof typeof values, variable: string): number {
1188
+ const text = values[flag] ?? process.env[variable] ?? '0';
1189
+ const port = Number(text);
1190
+ // Decimal digits only: \`Number\` also reads '' as 0 and takes '1e3' or '0x10'.
1191
+ if (!/^\\d+$/.test(text) || port > 65_535) {
1192
+ throw new Error(\`--\${flag} or \${variable} is '\${text}', not a port\`);
1193
+ }
1194
+ return port;
1195
+ }
1196
+
1197
+ const { shared } = create__NAME__Services({ ...NodeFileSystem });
1198
+ `;
1199
+ const portsLine = `
1200
+ // One JSON line on stdout once every head listens, for the process that started
1201
+ // this one to read the ports from: the one line that starts with \`{\`. Each
1202
+ // head brings the workspace up before it listens; a head that fails to start
1203
+ // has logged why and ends the process, which would otherwise serve without it.
1204
+ Promise.all([${servers.map(server => `${server}.started`).join(', ')}]).then(
1205
+ () => process.stdout.write(\`\${JSON.stringify({ ${servers.map(server => `${server}: ${server}.port`).join(', ')} })}\\n\`),
1206
+ () => process.exit(1)
1207
+ );
1208
+ `;
1209
+
1210
+ const invocation = lsp
1211
+ ? `// Invocation: \`node lib/main.js --stdio\`, or the package's \`__PROJECT_ID__\` bin
1212
+ // script.`
1213
+ : `// Invocation: \`node lib/main.js [<workspace-path>] ${portFlags.map(flag => `[${flag} <port>]`).join(' ')}\`,
1214
+ // or the package's \`__PROJECT_ID__\` bin script. Each port falls back to its
1215
+ // environment variable, then to any free port.`;
1216
+ const dataNote = !data
1217
+ ? ''
1218
+ : lsp
1219
+ ? `
1220
+ //
1221
+ // NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: stdio
1222
+ // here carries LSP, and the data head is a socket whose port is published over
1223
+ // the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
1224
+ : `
1225
+ //
1226
+ // NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: they
1227
+ // drive the data protocol over a child's stdio, and the data head here is a
1228
+ // socket. Those subcommands spawn \`data-server-main.js\`.`;
1229
+
1230
+ const text = `${SHEBANG}// Standalone entry point: starts ${headSummary} in the same
1231
+ // process, so every head shares one model store.
1232
+ ${invocation}${dataNote}
1233
+ //
1234
+ // Everything here runs at module scope, so this file is an executable rather
1235
+ // than a library entry — import \`./index.js\` instead to compose the language.
1236
+
1237
+ ${reflectImport}${glspImports}${importList(coreNodeSymbols, '@hydranium/core/node', columns)}
1238
+ ${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}${data && lsp ? "import { describeErrorWithStack } from '@hydranium/protocol';\n" : ''}${lsp ? lspImports : "import { parseArgs } from 'node:util';\n"}${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1239
+ ${portImports === '' ? '' : '\n' + portImports}${lsp ? lspStart : socketStart}${dataBlock}${glspBlock}${lsp ? '' : portsLine}`;
1136
1240
  return project(text, composition);
1137
1241
  }
1138
1242
 
@@ -1140,13 +1244,12 @@ ${dataBlock}${glspBlock}`;
1140
1244
  * The second executable entry: the data head alone, on stdio.
1141
1245
  *
1142
1246
  * Emitted with the `data` head because it is the ONLY command line that reaches
1143
- * that head from outside the editor. `main.ts` gives stdio to LSP and publishes
1144
- * the data head's socket port over the LSP connection, so a client that is not
1145
- * an LSP client cannot discover it — which is every data-server subcommand of
1146
- * `hydranium-cli`, since each drives the data protocol over a spawned child's
1147
- * stdin/stdout. Without this file a scaffolded project has no `--server` value,
1148
- * and the closest guess — the LSP entry — fails as an unrecognised method
1149
- * rather than as a missing head.
1247
+ * that head from outside the editor. `main.ts` serves the data head on a
1248
+ * socket, so a client driving the data protocol over a spawned child's
1249
+ * stdin/stdout cannot reach it — which is every data-server subcommand of
1250
+ * `hydranium-cli`. Without this file a scaffolded project has no `--server`
1251
+ * value, and the closest guess, `main.ts`, fails as an unrecognised method or
1252
+ * an unknown flag rather than as a missing head.
1150
1253
  *
1151
1254
  * A separate process rather than a flag on `main.ts`: only one protocol can own
1152
1255
  * stdio, so the two entries are mutually exclusive by construction.
@@ -1165,12 +1268,19 @@ function dataServerMainFile(composition: InitComposition): string {
1165
1268
  // package's \`__PROJECT_ID__-data-server\` bin script. The workspace path defaults to
1166
1269
  // the process cwd, which is what the CLI's \`--cwd\` sets on the child.
1167
1270
  //
1168
- // Contrast with \`main.ts\`, the editor entry: there the LSP head owns stdio and
1271
+ ${
1272
+ composition.heads.includes('lsp')
1273
+ ? `// Contrast with \`main.ts\`, the editor entry: there the LSP head owns stdio and
1169
1274
  // the data head is a socket published over the LSP connection. Here there is no
1170
1275
  // LSP connection at all, so the workspace initialization an
1171
1276
  // \`initialize\`/\`initialized\` pair would otherwise drive has to happen here —
1172
1277
  // which is why this entry uses \`startStdioServer\` rather than wiring a
1173
- // connection directly.
1278
+ // connection directly.`
1279
+ : `// Contrast with \`main.ts\`, where the data head is a socket whose port it
1280
+ // prints. Here it owns stdio, and as there, no LSP connection drives the
1281
+ // workspace initialization, so \`startStdioServer\` brings the workspace up
1282
+ // before it listens.`
1283
+ }
1174
1284
  //
1175
1285
  // Everything here runs at module scope, so this file is an executable rather
1176
1286
  // than a library entry — import \`./index.js\` instead to compose the language.
@@ -1504,8 +1614,9 @@ function readme(composition: InitComposition): string {
1504
1614
  '',
1505
1615
  '# Data-head operations. `--server` is a command line the CLI spawns and then',
1506
1616
  "# drives the data protocol over the child's stdin/stdout, so it has to name",
1507
- '# `data-server-main.js` — `main.js` gives stdio to LSP and answers these',
1508
- '# methods with "Unhandled method".',
1617
+ ...(composition.heads.includes('lsp')
1618
+ ? ['# `data-server-main.js` — `main.js` gives stdio to LSP and answers these', '# methods with "Unhandled method".']
1619
+ : ['# `data-server-main.js` — `main.js` serves the data head on a socket.']),
1509
1620
  '#',
1510
1621
  '# The workspace goes to the ENTRY, not to `--cwd`: `--cwd` re-roots the child,',
1511
1622
  '# so a relative entry path is refused by name (an absolute one is fine).',
@@ -1539,7 +1650,7 @@ function readme(composition: InitComposition): string {
1539
1650
  grammar =>
1540
1651
  `- \`src/glsp/${grammar.grammarId}/\` — the \`${grammar.grammar}\` diagram: state, storage, submission handler, GModel factory, configuration, create-node handler, DI module`
1541
1652
  ),
1542
- `- \`../protocol/\` — the \`${memberPackageName(composition, 'protocol')}\` package: what this server and its clients both import (language ids, ${portCommandNames(composition).length ? 'port commands, ' : ''}${composition.grammars.some(grammar => grammar.diagram) ? 'diagram ids, ' : ''}the transfer model), written partly by \`npm run generate\``,
1653
+ `- \`../protocol/\` — the \`${memberPackageName(composition, 'protocol')}\` package: what this server and its clients both import (language ids, ${headPortConstants(composition).length === 0 ? '' : composition.heads.includes('lsp') ? 'port commands, ' : 'port variables, '}${composition.grammars.some(grammar => grammar.diagram) ? 'diagram ids, ' : ''}the transfer model), written partly by \`npm run generate\``,
1543
1654
  '- `test/services.test.ts` — the DI tree composes; grows as your language does',
1544
1655
  '- `test/parsing.test.ts` / `linking.test.ts` / `validating.test.ts` — the three tiers a new language breaks first',
1545
1656
  '- `test/serialization.test.ts` — each grammar round-trips through its serializer',
@@ -1928,7 +2039,7 @@ function protocolIndexFile(composition: InitComposition): string {
1928
2039
  const modules = [
1929
2040
  './generated-hydranium/languages.js',
1930
2041
  './generated-hydranium/transfer-model.js',
1931
- ...(portCommandNames(composition).length ? ['./head-ports.js'] : []),
2042
+ ...(headPortConstants(composition).length ? ['./head-ports.js'] : []),
1932
2043
  ...composition.grammars.filter(grammar => grammar.diagram).map(grammar => `./${grammar.grammarId}-diagram-types.js`)
1933
2044
  ];
1934
2045
  const text = `// What the __NAME__ server and its clients both import. A client depends on
@@ -1953,7 +2064,11 @@ function protocolReadme(composition: InitComposition): string {
1953
2064
  '',
1954
2065
  '- `src/generated-hydranium/languages.ts` — each language id and its file extensions',
1955
2066
  '- `src/generated-hydranium/transfer-model.ts` — the wire types of the model, references as plain strings',
1956
- ...(portCommandNames(composition).length ? ['- `src/head-ports.ts` — the LSP requests a host sends to find the socket heads'] : []),
2067
+ ...(headPortConstants(composition).length === 0
2068
+ ? []
2069
+ : composition.heads.includes('lsp')
2070
+ ? ['- `src/head-ports.ts` — the LSP requests a host sends to find the socket heads']
2071
+ : ["- `src/head-ports.ts` — the environment variables naming the socket heads' ports"]),
1957
2072
  ...composition.grammars
1958
2073
  .filter(grammar => grammar.diagram)
1959
2074
  .map(grammar => `- \`src/${grammar.grammarId}-diagram-types.ts\` — the \`${grammar.grammar}\` diagram's type ids`),
@@ -1974,7 +2089,7 @@ function protocolFiles(composition: InitComposition): InitFile[] {
1974
2089
  ignoreFile('protocol'),
1975
2090
  { path: 'protocol/README.md', content: protocolReadme(composition) },
1976
2091
  { path: 'protocol/src/index.ts', content: protocolIndexFile(composition) },
1977
- ...(portCommandNames(composition).length ? [{ path: 'protocol/src/head-ports.ts', content: headPortsFile(composition) }] : []),
2092
+ ...(headPortConstants(composition).length ? [{ path: 'protocol/src/head-ports.ts', content: headPortsFile(composition) }] : []),
1978
2093
  ...composition.grammars
1979
2094
  .filter(grammar => grammar.diagram)
1980
2095
  .map(grammar => grammarTemplate('protocol/src/__GRAMMAR_ID__-diagram-types.ts', composition, grammar))
@@ -57,7 +57,7 @@
57
57
  import * as path from 'node:path';
58
58
  import { DEFAULT_INIT_HEADS, INIT_HEADS, type InitHead, isNonEmptyDir, isUsableExtension, memberPlacements } from './init.js';
59
59
  import { kebab } from './kebab.js';
60
- import { InitWizardCancelled, OPTIONAL_HEAD_CHOICES, type PromptPort } from './init-prompt.js';
60
+ import { HEAD_CHOICES, InitWizardCancelled, type PromptPort } from './init-prompt.js';
61
61
  import { type WorkspaceDetection } from './init-workspace.js';
62
62
 
63
63
  /** One grammar as the wizard collected it. */
@@ -199,22 +199,10 @@ async function askGrammar(
199
199
  return { name, extension: extension.replace(/^\./, ''), diagram: false };
200
200
  }
201
201
 
202
- /**
203
- * Ask for the protocol head set.
204
- *
205
- * `lsp` is not offered, it is stated: it owns the workspace, the build pipeline
206
- * and the shared tier the other heads read through, so a project without it has
207
- * nothing for them to serve. Presenting it as a checkbox would offer a choice
208
- * the scaffolder then refuses.
209
- */
202
+ /** Ask for the protocol head set, in composition order whatever order they were picked in. */
210
203
  async function askHeads(prompt: PromptPort): Promise<InitHead[]> {
211
- const optional = await prompt.multiselect({
212
- message: 'Protocol heads — lsp is always included',
213
- choices: OPTIONAL_HEAD_CHOICES,
214
- initialValues: DEFAULT_INIT_HEADS.filter((head): head is Exclude<InitHead, 'lsp'> => head !== 'lsp')
215
- });
216
- const chosen = new Set<InitHead>(['lsp', ...optional]);
217
- return INIT_HEADS.filter(head => chosen.has(head));
204
+ const chosen = await prompt.multiselect({ message: 'Protocol heads', choices: HEAD_CHOICES, initialValues: DEFAULT_INIT_HEADS });
205
+ return INIT_HEADS.filter(head => chosen.includes(head));
218
206
  }
219
207
 
220
208
  /**
@@ -22,11 +22,8 @@ import {
22
22
  } from './init-workspace.js';
23
23
 
24
24
  /**
25
- * The protocol heads a scaffold can start, in composition order.
26
- *
27
- * `lsp` is mandatory rather than optional: it owns the workspace, the build
28
- * pipeline and the shared service tier every other head reads through, so a
29
- * project without it has nothing for `data` or `glsp` to serve.
25
+ * The protocol heads a scaffold can start, in composition order. Any non-empty
26
+ * set: without `lsp`, the socket heads bring the workspace up themselves.
30
27
  */
31
28
  export const INIT_HEADS = ['lsp', 'data', 'glsp'] as const;
32
29
 
@@ -228,7 +225,7 @@ export interface InitComposition {
228
225
  * one data server per project, not one per language).
229
226
  */
230
227
  readonly projectId: string;
231
- /** The protocol heads to start, always including `lsp`. */
228
+ /** The protocol heads to start, at least one. */
232
229
  readonly heads: readonly InitHead[];
233
230
  /** The grammars, in the order given. The first is the configuration root. */
234
231
  readonly grammars: readonly InitGrammarNames[];
@@ -378,18 +375,13 @@ function resolveDiagrams(specs: readonly InitGrammarSpec[], heads: readonly Init
378
375
  );
379
376
  }
380
377
 
381
- /** Validate the head set: known names only, and `lsp` present. */
378
+ /** Validate the head set: known names only. */
382
379
  function resolveHeads(heads: readonly InitHead[] | undefined): readonly InitHead[] {
383
380
  const resolved = heads === undefined || heads.length === 0 ? DEFAULT_INIT_HEADS : heads;
384
381
  const unknown = resolved.filter(head => !isInitHead(head));
385
382
  if (unknown.length > 0) {
386
383
  throw new Error(`Unknown head(s) '${unknown.join(', ')}': expected one of ${INIT_HEADS.join(', ')}.`);
387
384
  }
388
- if (!resolved.includes('lsp')) {
389
- throw new Error(
390
- "--heads must include 'lsp': it owns the workspace, the build pipeline and the shared service tier the other heads read through."
391
- );
392
- }
393
385
  // De-duplicated and put back in composition order, so the order the heads
394
386
  // were named in does not change the emitted project.
395
387
  return INIT_HEADS.filter(head => resolved.includes(head));
@@ -6,7 +6,7 @@
6
6
  // pointing at an id that does not exist fails client-side with a far less
7
7
  // obvious error than a missing edge. An unresolved reference is SKIPPED rather
8
8
  // than treated as an error: a dangling reference is ordinary editing state, and
9
- // the LSP head already reports it as a diagnostic. A reference into another
9
+ // the language already reports it as a diagnostic. A reference into another
10
10
  // document is skipped for the same reason: the graph holds only this
11
11
  // document's nodes, so its target has no id here.
12
12
 
@@ -3,7 +3,8 @@
3
3
  // take it out and put it back; every step must keep the nodes the document
4
4
  // already had, and its comments, since the diagram writes the document from its
5
5
  // AST, which carries none. A reference into another document draws no edge,
6
- // since the graph holds no node for its target.
6
+ // since the graph holds no node for its target, but resolves, even when the
7
+ // diagram opened its document before the workspace was up.
7
8
 
8
9
  import 'reflect-metadata';
9
10
  import { type Action, CreateNodeOperation, RedoAction, ServerModule, SetDirtyStateAction, UndoAction } from '@eclipse-glsp/server';
@@ -86,4 +87,26 @@ describe('__GRAMMAR__ diagram', () => {
86
87
  expect(diagram.state.sourceRoot?.nodes[0].target?.ref?.name).toBe('second');
87
88
  expect(diagram.state.root.children.map(child => child.type)).toEqual([__GRAMMAR_ID_CONST___NODE_TYPE]);
88
89
  });
90
+
91
+ it('resolves a cross-document reference in a document opened before the workspace is up', async () => {
92
+ workspace = makeScratchWorkspace({ prefix: '__GRAMMAR_ID__-diagram-' });
93
+ workspace.write('declaring.__EXTENSION__', 'node second');
94
+ const file = workspace.write('referencing.__EXTENSION__', 'node first -> second');
95
+ const { shared } = createServices();
96
+ diagram = makeGlspHarness<__GRAMMAR__GlspState>({
97
+ serverModule: new ServerModule().configureDiagramModule(new __GRAMMAR__DiagramModule()),
98
+ diagramType: __GRAMMAR_ID_CONST___DIAGRAM_TYPE,
99
+ appModules: [new HydraniumGlspAppModule({ shared })]
100
+ });
101
+ await diagram.start();
102
+ // The open waits for the workspace. One that did not would have linked
103
+ // within this timer against a workspace holding only itself, and the
104
+ // bring-up does not relink it. Its budget covers the bring-up as well.
105
+ const opening = diagram.openDocument(file, 10_000);
106
+ await new Promise(resolve => setTimeout(resolve, 200));
107
+ await initializeWorkspaceProgrammatically(shared, workspace.root);
108
+ await opening;
109
+
110
+ expect(diagram.state.sourceRoot?.nodes[0].target?.ref?.name).toBe('second');
111
+ });
89
112
  });