@loomweaver/cli 0.15.2 → 0.17.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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/dist/main.mjs +809 -311
  3. package/package.json +1 -1
package/dist/main.mjs CHANGED
@@ -126,6 +126,7 @@ function amendments(recipe, input) {
126
126
  }
127
127
 
128
128
  // ../devkit/src/lib/amend/merge.ts
129
+ import { posix } from "node:path";
129
130
  function normalizeProjectRoot(value) {
130
131
  const rooted = value.replace(/^\.?\/*/, "");
131
132
  let end = rooted.length;
@@ -141,27 +142,6 @@ function joinProjectPath(projectRoot, path) {
141
142
  function resolveAssetInput(glob, projectRoot) {
142
143
  return glob.from === "project" ? joinProjectPath(projectRoot, glob.input) : glob.input;
143
144
  }
144
- function ensurePostcssPlugin(existing, amendment) {
145
- const root = asObject(existing) ?? {};
146
- const plugins = asObject(root["plugins"]);
147
- if (plugins === void 0 && root["plugins"] !== void 0) {
148
- return {
149
- value: root,
150
- added: [],
151
- declined: [`${amendment.file}: "plugins" is not an object`]
152
- };
153
- }
154
- const next = { ...plugins };
155
- if (Object.hasOwn(next, amendment.plugin)) {
156
- return { value: root, added: [], declined: [] };
157
- }
158
- next[amendment.plugin] = {};
159
- return {
160
- value: { ...root, plugins: next },
161
- added: [`${amendment.file}: ${amendment.plugin}`],
162
- declined: []
163
- };
164
- }
165
145
  function ensureDependency(manifest2, amendment) {
166
146
  const root = asObject(manifest2) ?? {};
167
147
  const dependencies = asObject(root["dependencies"]);
@@ -358,7 +338,7 @@ function ensureAssets(existing, wanted, projectRoot) {
358
338
  const added = [];
359
339
  for (const glob of wanted) {
360
340
  const input = resolveAssetInput(glob, projectRoot);
361
- if (list2.some((entry) => inputOf(entry) === input)) {
341
+ if (list2.some((entry) => inputOf(entry) === input || serves(entry, input, glob.output))) {
362
342
  continue;
363
343
  }
364
344
  list2.push({
@@ -370,6 +350,16 @@ function ensureAssets(existing, wanted, projectRoot) {
370
350
  }
371
351
  return { value: list2, added };
372
352
  }
353
+ function serves(entry, input, output = "") {
354
+ const asset = asObject(entry);
355
+ const from = inputOf(entry);
356
+ if (!asset || from === void 0 || !["**", "**/*"].includes(String(asset["glob"]))) {
357
+ return false;
358
+ }
359
+ const below = posix.relative(from, input);
360
+ const servedAt = posix.join(String(asset["output"] ?? ""), below);
361
+ return !below.startsWith("..") && below !== "" && servedAt === posix.normalize(output);
362
+ }
373
363
  function inputOf(entry) {
374
364
  if (typeof entry === "string") {
375
365
  return entry;
@@ -380,8 +370,61 @@ function inputOf(entry) {
380
370
  function asObject(value) {
381
371
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
382
372
  }
373
+ function usesTailwind(css) {
374
+ return css.split("\n").some((line) => {
375
+ const directive = line.trimStart();
376
+ return /^@import\s+['"]tailwindcss['"]/.test(directive) || /^@source\s/.test(directive);
377
+ });
378
+ }
379
+ function entryStylesheet(styles) {
380
+ if (!Array.isArray(styles)) {
381
+ return void 0;
382
+ }
383
+ return styles.map((style) => inputOf(style)).find((input) => input?.endsWith(".css") === true);
384
+ }
385
+ function relativeImport(fromDirectory, toDirectory) {
386
+ const path = posix.relative(fromDirectory, toDirectory);
387
+ if (path === "") {
388
+ return ".";
389
+ }
390
+ return path.startsWith("..") ? path : `./${path}`;
391
+ }
392
+
393
+ // ../devkit/src/lib/amend/postcss.ts
394
+ function ensurePostcssPlugin(existing, amendment) {
395
+ const root = asObject(existing) ?? {};
396
+ const plugins = asObject(root["plugins"]);
397
+ if (plugins === void 0 && root["plugins"] !== void 0) {
398
+ return {
399
+ value: root,
400
+ added: [],
401
+ declined: [`${amendment.file}: "plugins" is not an object`]
402
+ };
403
+ }
404
+ const next = { ...plugins };
405
+ if (Object.hasOwn(next, amendment.plugin)) {
406
+ return { value: root, added: [], declined: [] };
407
+ }
408
+ next[amendment.plugin] = {};
409
+ return {
410
+ value: { ...root, plugins: next },
411
+ added: [`${amendment.file}: ${amendment.plugin}`],
412
+ declined: []
413
+ };
414
+ }
415
+ var CODE_POSTCSS_CONFIGS = [
416
+ "postcss.config.js",
417
+ "postcss.config.mjs",
418
+ "postcss.config.cjs",
419
+ ".postcssrc.js"
420
+ ];
421
+ function postcssWrittenAsCode(exists, amendment) {
422
+ const inTheWay = CODE_POSTCSS_CONFIGS.find((name) => exists(name));
423
+ return inTheWay === void 0 ? void 0 : `${inTheWay} is written as code and cannot be merged into, so add ${amendment.plugin} to it yourself; until then the stylesheet emits no utility class and the workbench renders unstyled.`;
424
+ }
383
425
 
384
426
  // ../devkit/src/lib/amend/compose.ts
427
+ import { posix as posix2 } from "node:path";
385
428
  var APP_CONFIG = /export\s+const\s+appConfig\s*:[^=]*=\s*\{/;
386
429
  var NAMESPACES = /provideTranslationNamespaces\(([^)]*)\)/;
387
430
  var PROVIDERS_OPEN = /providers\s*:\s*\[/g;
@@ -426,15 +469,12 @@ function composePlugin(source, amendment, importPath) {
426
469
  }
427
470
  const wanted = providersToAdd(source, amendment);
428
471
  const ownSymbols = [amendment.symbol, ...wanted.flatMap((provider) => provider.own ?? [])];
429
- const foreign = wanted.flatMap((provider) => provider.from ?? []);
430
472
  const withImports = source.replace(
431
473
  SHELL_IMPORT,
432
474
  () => [
433
- `import {${withShellSymbols(shellImport[1], wanted)}} from '@loomweaver/shell';`,
434
- ...foreign.map(
435
- (entry) => `import { ${[...entry.symbols].toSorted((a, b) => a.localeCompare(b)).join(", ")} } from '${entry.path}';`
436
- ),
437
- `import { ${ownSymbols.toSorted((a, b) => a.localeCompare(b)).join(", ")} } from '${importPath}';`
475
+ `import {${withShellSymbols(shellImport[1], [...REGISTRATION_SHELL_SYMBOLS, ...shellSymbolsOf(wanted)])}} from '@loomweaver/shell';`,
476
+ ...foreignImports(wanted),
477
+ `import { ${sorted(ownSymbols).join(", ")} } from '${importPath}';`
438
478
  ].join("\n")
439
479
  );
440
480
  const joined = joinNamespaces(withImports, amendment.id);
@@ -452,6 +492,71 @@ ${lines}${joined.source.slice(block.insertAt)}`,
452
492
  kept: keptProviders(source, amendment).map((provider) => provider.line)
453
493
  };
454
494
  }
495
+ function composeProviders(source, amendment, importPath) {
496
+ const pending = amendment.providers.filter(
497
+ (provider) => !source.includes(provider.line.replace(/,$/, ""))
498
+ );
499
+ const kept = pending.filter(
500
+ (provider) => provider.unless !== void 0 && source.includes(provider.unless)
501
+ );
502
+ const wanted = pending.filter((provider) => !kept.includes(provider));
503
+ const keptLines = kept.map((provider) => provider.line);
504
+ if (wanted.length === 0) {
505
+ return { source, composed: true, kept: keptLines };
506
+ }
507
+ const shellImport = SHELL_IMPORT.exec(source);
508
+ if (!shellImport || !providersBlock(source)) {
509
+ return { source, composed: false, kept: keptLines };
510
+ }
511
+ const own = wanted.flatMap((provider) => provider.own ?? []);
512
+ const withImports = source.replace(
513
+ SHELL_IMPORT,
514
+ () => [
515
+ `import {${withShellSymbols(shellImport[1], shellSymbolsOf(wanted))}} from '@loomweaver/shell';`,
516
+ ...foreignImports(wanted),
517
+ ...own.length > 0 && importPath !== void 0 ? [`import { ${sorted(own).join(", ")} } from '${importPath}';`] : []
518
+ ].join("\n")
519
+ );
520
+ const block = providersBlock(withImports);
521
+ if (!block) {
522
+ return { source, composed: false, kept: keptLines };
523
+ }
524
+ const lines = wanted.map((provider) => `${block.indent} ${provider.line}`).join("\n");
525
+ return {
526
+ source: `${withImports.slice(0, block.insertAt)}
527
+ ${lines}${withImports.slice(block.insertAt)}`,
528
+ composed: true,
529
+ kept: keptLines
530
+ };
531
+ }
532
+ function providerLines(amendment, importPath) {
533
+ const own = amendment.providers.flatMap((provider) => provider.own ?? []);
534
+ return [
535
+ `import { ${shellSymbolsOf(amendment.providers).join(", ")} } from '@loomweaver/shell';`,
536
+ ...foreignImports(amendment.providers),
537
+ ...own.length > 0 && importPath !== void 0 ? [`import { ${own.join(", ")} } from '${importPath}';`] : [],
538
+ ...amendment.providers.map((provider) => provider.line)
539
+ ];
540
+ }
541
+ function moduleImport(fromDirectory, module) {
542
+ if (module === void 0) {
543
+ return void 0;
544
+ }
545
+ const directory = relativeImport(fromDirectory, posix2.dirname(module));
546
+ return `${directory}/${posix2.basename(module)}`;
547
+ }
548
+ function keptNote(line) {
549
+ return `kept the ${line.replace(/^\.\.\./, "").split("(", 1)[0]} already there instead of ${line.replace(/,$/, "")}`;
550
+ }
551
+ function shellSymbolsOf(providers) {
552
+ return providers.flatMap((provider) => provider.shell ?? []);
553
+ }
554
+ function foreignImports(providers) {
555
+ return providers.flatMap((provider) => provider.from ?? []).map((entry) => `import { ${sorted(entry.symbols).join(", ")} } from '${entry.path}';`);
556
+ }
557
+ function sorted(symbols) {
558
+ return symbols.toSorted((a, b) => a.localeCompare(b));
559
+ }
455
560
  function joinNamespaces(source, id) {
456
561
  const existing = NAMESPACES.exec(source);
457
562
  if (!existing) {
@@ -498,21 +603,20 @@ function registrationLines(amendment, providers) {
498
603
  return [
499
604
  ...providers.map((provider) => provider.line),
500
605
  namespaceLine(amendment.id),
501
- `provideCapabilityGrants({ ${amendment.id}: [${quotedList(amendment.capabilities)}] }),`,
606
+ `provideCapabilityGrants({ ${grantKey(amendment.id)}: [${quotedList(amendment.capabilities)}] }),`,
502
607
  `...providePlugins(${amendment.symbol}),`
503
608
  ];
504
609
  }
610
+ function grantKey(id) {
611
+ return /^[A-Za-z_$][\w$]*$/.test(id) ? id : `'${id}'`;
612
+ }
505
613
  function quotedList(values) {
506
614
  return values.map((value) => `'${value}'`).join(", ");
507
615
  }
508
616
  function namespaceLine(id) {
509
617
  return `provideTranslationNamespaces('${id}'),`;
510
618
  }
511
- function withShellSymbols(existing, providers) {
512
- const wanted = [
513
- ...REGISTRATION_SHELL_SYMBOLS,
514
- ...providers.flatMap((provider) => provider.shell ?? [])
515
- ];
619
+ function withShellSymbols(existing, wanted) {
516
620
  const present = existing.split(",").map((symbol) => symbol.trim()).filter(Boolean);
517
621
  const missing = wanted.filter(
518
622
  (symbol) => present.every((entry) => entry.replace(/^type\s+/, "") !== symbol)
@@ -527,6 +631,29 @@ function withShellSymbols(existing, providers) {
527
631
  ` : ` ${all.join(", ")} `;
528
632
  }
529
633
 
634
+ // ../devkit/src/lib/amend/stylesheet-import.ts
635
+ import { posix as posix3 } from "node:path";
636
+ var IMPORT = /^\s*@import\s+(?:url\(\s*)?['"]([^'"]+)['"]/;
637
+ function specifierOf(line) {
638
+ return IMPORT.exec(line)?.[1];
639
+ }
640
+ function importSpecifier(entryStylesheet2, file) {
641
+ const path = posix3.relative(posix3.dirname(entryStylesheet2), file);
642
+ return path.startsWith("..") ? path : `./${path}`;
643
+ }
644
+ function ensureStylesheetImport(css, specifier, after) {
645
+ const lines = css.split("\n");
646
+ if (lines.some((line) => specifierOf(line) === specifier)) {
647
+ return { css, anchored: true };
648
+ }
649
+ const anchor = lines.findLastIndex(
650
+ (line) => specifierOf(line)?.startsWith(after) === true
651
+ );
652
+ const position = anchor === -1 ? lines.findLastIndex((line) => specifierOf(line) !== void 0) : anchor;
653
+ lines.splice(position + 1, 0, `@import '${specifier}';`);
654
+ return { css: lines.join("\n"), anchored: anchor !== -1 };
655
+ }
656
+
530
657
  // ../devkit/src/lib/amend/describe.ts
531
658
  function describeAmendment(amendment) {
532
659
  switch (amendment.kind) {
@@ -542,6 +669,12 @@ function describeAmendment(amendment) {
542
669
  case "compose-plugin": {
543
670
  return describeRegistration(amendment);
544
671
  }
672
+ case "compose-provider": {
673
+ return describeProviders(amendment);
674
+ }
675
+ case "stylesheet-import": {
676
+ return `Import '${amendment.file}' in the application's entry stylesheet, resolved from that stylesheet, after its import of ${amendment.after}. ${amendment.without}`;
677
+ }
545
678
  case "build-target": {
546
679
  return describeBuildTarget(amendment);
547
680
  }
@@ -560,6 +693,18 @@ function describeRegistration(amendment) {
560
693
  ).join("");
561
694
  return `Register ${amendment.id} in the composition root: import { ${amendment.symbol} }, ${calls.slice(0, -1).join(", ")} and ${calls.at(-1)}.${imports} Without it none of its contributions appear.`;
562
695
  }
696
+ function describeProviders(amendment) {
697
+ const lines = providerLines(amendment, amendment.module).map((line) => line.replace(/[,;]$/, "")).join("; ");
698
+ const resolved = amendment.module === void 0 ? "" : " Resolve the generated import from the composition root.";
699
+ const keeps = amendment.providers.map((provider) => provider.unless).filter((unless) => unless?.endsWith("(") === true).map((unless) => unless.slice(0, -1));
700
+ const keep = keeps.length > 0 ? ` Where the composition already calls ${keeps.join(" or ")}, that one is the product's choice: keep it, or replace it with this one.` : "";
701
+ return `Add to the composition root, after provideShell(): ${lines}.${resolved}${keep} ${amendment.without}`;
702
+ }
703
+ var STRINGS_COST = "the shell fetches its own strings at runtime, so without that glob every label in the chrome renders as its raw translation key";
704
+ function assetCost(amendment) {
705
+ const own = amendment.assets.map((asset) => asset.without).filter((without) => without !== void 0);
706
+ return own.length > 0 ? own.join("; ") : STRINGS_COST;
707
+ }
563
708
  function describeBuildTarget(amendment) {
564
709
  const steps = [];
565
710
  if (amendment.styles.length > 0) {
@@ -569,9 +714,7 @@ function describeBuildTarget(amendment) {
569
714
  const assets = amendment.assets.map(
570
715
  (asset) => asset.output ? `${asset.input} served under ${asset.output}` : asset.input
571
716
  ).join(", ");
572
- steps.push(
573
- `add assets for ${assets} (the shell fetches its own strings at runtime, so without that glob every label in the chrome renders as its raw translation key)`
574
- );
717
+ steps.push(`add assets for ${assets} (${assetCost(amendment)})`);
575
718
  }
576
719
  if (amendment.serviceWorker) {
577
720
  steps.push(
@@ -922,16 +1065,30 @@ function pause(): Promise<void> {
922
1065
  `;
923
1066
  }
924
1067
 
1068
+ // ../devkit/src/lib/generate/escape.ts
1069
+ var MARKUP_ENTITIES = {
1070
+ "&": "&amp;",
1071
+ "<": "&lt;",
1072
+ ">": "&gt;",
1073
+ '"': "&quot;",
1074
+ "'": "&#39;",
1075
+ "{": "&#123;",
1076
+ "}": "&#125;",
1077
+ "@": "&#64;"
1078
+ };
1079
+ function markupText(value) {
1080
+ return value.replaceAll(/[&<>"'{}@]/g, (character) => MARKUP_ENTITIES[character]);
1081
+ }
1082
+ function stringLiteral(value) {
1083
+ const escaped = value.replaceAll("\\", "\\\\").replaceAll("'", String.raw`\'`).replaceAll("\n", String.raw`\n`).replaceAll("\r", String.raw`\r`);
1084
+ return `'${escaped}'`;
1085
+ }
1086
+
925
1087
  // ../devkit/src/recipes/angular-weaver/agent-files.ts
926
1088
  var AG_UI_PROTOCOL_VERSION = "0.0.x";
927
1089
  function connectionFile(weaver) {
928
1090
  return `import { signal } from '@angular/core';
929
- import {
930
- commandTools,
931
- type CommandTools,
932
- type PendingToolCall,
933
- type ToolDecision,
934
- } from '@loomweaver/ag-ui';
1091
+ import { commandTools, consentPolicy, type CommandTools } from '@loomweaver/ag-ui';
935
1092
  import type { PluginContext } from '@loomweaver/plugin-sdk';
936
1093
 
937
1094
  // The connection is the workbench's half: the commands offered as tools, and each call put to the
@@ -940,43 +1097,28 @@ import type { PluginContext } from '@loomweaver/plugin-sdk';
940
1097
  // A factory, not a module-level connection: everything a run needs lives in the closure, so a second
941
1098
  // one never shares state with the first.
942
1099
  export function connect${weaver.className}(ctx: PluginContext): CommandTools {
943
- return commandTools(ctx, { before: (call) => decide(ctx, call) });
1100
+ // What an agent's word is enough for is each command's own statement, declared where the command
1101
+ // is registered. consentPolicy acts on it: it declines what may never run on an agent's word, runs
1102
+ // what may, and asks through your confirmation for the rest, remembering a yes for a command that
1103
+ // asks first for as long as this connection lives. The workbench states and enforces nothing here,
1104
+ // so replace it with a decision of your own where you need one. A decision can only narrow: the
1105
+ // workbench still refuses whatever it always refused.
1106
+ return commandTools(ctx, {
1107
+ before: consentPolicy(() =>
1108
+ ctx.ui.confirm({
1109
+ title: '${weaver.id}.agent.confirm.title',
1110
+ message: '${weaver.id}.agent.confirm.message',
1111
+ confirmLabel: '${weaver.id}.agent.confirm.yes',
1112
+ cancelLabel: '${weaver.id}.agent.confirm.no',
1113
+ tone: 'warning',
1114
+ }),
1115
+ ),
1116
+ });
944
1117
  }
945
1118
 
946
1119
  // The one connection this plugin activates, published for its panel. Set in activate(), cleared in
947
1120
  // deactivate(), so the panel renders an honest empty state either side of that.
948
1121
  export const ${weaver.propertyName}Tools = signal<CommandTools | null>(null);
949
-
950
- // What an agent's word is enough for is the command's own statement, declared where the command is
951
- // registered and read off the call here. No list of ids lives beside the commands: a list drifts from
952
- // what it describes, and it cannot speak for a command another plugin registered. Acting on the
953
- // statement is this weaver's half: the workbench states it and enforces nothing.
954
- async function decide(
955
- ctx: PluginContext,
956
- call: PendingToolCall,
957
- ): Promise<ToolDecision> {
958
- if (call.agentConsent === 'never') {
959
- return {
960
- decision: 'decline',
961
- reason: 'an agent may not run this one on its own word.',
962
- };
963
- }
964
- if (call.agentConsent !== 'ask' && call.agentConsent !== 'ask-always') {
965
- return { decision: 'run' };
966
- }
967
- const yes = await ctx.ui.confirm({
968
- title: '${weaver.id}.agent.confirm.title',
969
- message: '${weaver.id}.agent.confirm.message',
970
- confirmLabel: '${weaver.id}.agent.confirm.yes',
971
- cancelLabel: '${weaver.id}.agent.confirm.no',
972
- tone: 'warning',
973
- });
974
- // A decision can only narrow. Letting a call through does not make it reachable: the workbench
975
- // still refuses whatever it always refused.
976
- return yes
977
- ? { decision: 'run' }
978
- : { decision: 'decline', reason: 'the person at the keyboard said no.' };
979
- }
980
1122
  `;
981
1123
  }
982
1124
  function specFile(weaver) {
@@ -990,17 +1132,22 @@ interface Asked {
990
1132
  readonly args?: CommandArguments;
991
1133
  }
992
1134
 
993
- function contextThat(confirms: boolean, ran: Asked[]): PluginContext {
1135
+ function contextThat(confirms: boolean, ran: Asked[], asked: string[] = []): PluginContext {
994
1136
  return {
995
1137
  invocableCommands: () => [
996
1138
  // agentConsent travels with the command, which is what the connection reads off the call.
997
- { id: '${weaver.id}.hello', title: '${weaver.name} action', description: 'Shows a short message.', agentConsent: 'ask' },
1139
+ { id: '${weaver.id}.hello', title: ${stringLiteral(`${weaver.name} action`)}, description: 'Shows a short message.', agentConsent: 'ask' },
998
1140
  ],
999
1141
  invokeCommand: (id: string, args?: CommandArguments) => {
1000
1142
  ran.push({ id, args });
1001
1143
  return Promise.resolve({ outcome: 'answered', value: { tone: args?.['tone'] ?? 'info' } });
1002
1144
  },
1003
- ui: { confirm: () => Promise.resolve(confirms) },
1145
+ ui: {
1146
+ confirm: (input: { title: string }) => {
1147
+ asked.push(input.title);
1148
+ return Promise.resolve(confirms);
1149
+ },
1150
+ },
1004
1151
  } as unknown as PluginContext;
1005
1152
  }
1006
1153
 
@@ -1040,6 +1187,18 @@ describe('connect${weaver.className}', () => {
1040
1187
  expect(answer?.error).toBeUndefined();
1041
1188
  });
1042
1189
 
1190
+ it('asks once for a command that asks first, and remembers the yes', async () => {
1191
+ const asked: string[] = [];
1192
+ const ran: Asked[] = [];
1193
+ const tools = connect${weaver.className}(contextThat(true, ran, asked));
1194
+
1195
+ await answersTo(tools, streamedCall('c3', '{}'));
1196
+ await answersTo(tools, streamedCall('c4', '{}'));
1197
+
1198
+ expect(asked).toEqual(['${weaver.id}.agent.confirm.title']);
1199
+ expect(ran).toHaveLength(2);
1200
+ });
1201
+
1043
1202
  it('never reaches the workbench when a consequential call is declined', async () => {
1044
1203
  const ran: Asked[] = [];
1045
1204
  const tools = connect${weaver.className}(contextThat(false, ran));
@@ -1149,6 +1308,12 @@ var APP_OPTION = {
1149
1308
  description: "Application to drop into. Inferred when the workspace has exactly one.",
1150
1309
  workspaceOnly: true
1151
1310
  };
1311
+ var DIRECTORY_OPTION = {
1312
+ name: "directory",
1313
+ type: "string",
1314
+ description: "Where the files land, relative to the workspace root.",
1315
+ workspaceOnly: true
1316
+ };
1152
1317
 
1153
1318
  // ../devkit/src/lib/validate/manifest.ts
1154
1319
  var KNOWN_CAPABILITIES = [
@@ -1162,11 +1327,18 @@ var KNOWN_CAPABILITIES = [
1162
1327
  ];
1163
1328
  function validateManifest(manifest2, known = KNOWN_CAPABILITIES) {
1164
1329
  const findings = [];
1165
- if (typeof manifest2.id !== "string" || !isKebabId(manifest2.id)) {
1330
+ if (typeof manifest2.id !== "string" || manifest2.id.trim() === "") {
1166
1331
  findings.push({
1167
1332
  level: "error",
1168
1333
  code: "manifest.id",
1169
- message: `Plugin id must be a kebab-case string; got ${JSON.stringify(manifest2.id)}.`,
1334
+ message: `Plugin id must be a non-empty string; got ${JSON.stringify(manifest2.id)}. Grants, settings and every contribution are keyed by it.`,
1335
+ path: "manifest.id"
1336
+ });
1337
+ } else if (!isKebabId(manifest2.id)) {
1338
+ findings.push({
1339
+ level: "warning",
1340
+ code: "manifest.id.convention",
1341
+ message: `Plugin id ${JSON.stringify(manifest2.id)} is not kebab-case. The workbench accepts it; every scaffold and guide writes ids in kebab-case, so it will read differently from the plugins beside it.`,
1170
1342
  path: "manifest.id"
1171
1343
  });
1172
1344
  }
@@ -1258,6 +1430,13 @@ var PLATFORM_BOUND_CHORD_TOKENS = /* @__PURE__ */ new Set([
1258
1430
  "control",
1259
1431
  "meta"
1260
1432
  ]);
1433
+ var KEPT_BY_BROWSERS = /* @__PURE__ */ new Set(["n", "t", "w", "q"]);
1434
+ function defaultShortcut(id) {
1435
+ const letter = [...id].find(
1436
+ (character) => /[a-z]/.test(character) && !KEPT_BY_BROWSERS.has(character)
1437
+ );
1438
+ return letter && `mod+shift+${letter}`;
1439
+ }
1261
1440
  function assertPlatformNeutralChord(shortcut) {
1262
1441
  const tokens = shortcut.toLowerCase().split("+").map((token) => token.trim());
1263
1442
  const bound = tokens.find((token) => PLATFORM_BOUND_CHORD_TOKENS.has(token));
@@ -1287,7 +1466,7 @@ function resolveFeatures(id, input) {
1287
1466
  menuSlot,
1288
1467
  settings: Boolean(input?.settings),
1289
1468
  access: input?.access ? accessLiteral(input.access) : void 0,
1290
- shortcut: input?.shortcut?.trim() || `mod+shift+${id.charAt(0)}`,
1469
+ shortcut: input?.shortcut?.trim() || defaultShortcut(id),
1291
1470
  barItem,
1292
1471
  about: Boolean(input?.about),
1293
1472
  instanceable,
@@ -1352,7 +1531,7 @@ function commandBlock(weaver) {
1352
1531
  ` { name: 'tone', kind: 'choice', choices: ['info', 'success', 'warning'], description: '${weaver.id}.actionTone' },`,
1353
1532
  " ],",
1354
1533
  ` answers: '${weaver.id}.actionAnswers',`,
1355
- ` shortcut: '${weaver.features.shortcut}',`,
1534
+ ...weaver.features.shortcut ? [` shortcut: '${weaver.features.shortcut}',`] : [],
1356
1535
  " callable: true,",
1357
1536
  ...weaver.features.agent ? [
1358
1537
  " // What an agent's word is enough for. The platform states it and enforces nothing:",
@@ -1584,7 +1763,7 @@ ${pluginConsts(weaver).join("\n\n")}
1584
1763
  export const ${weaver.propertyName}Plugin: Plugin = {
1585
1764
  manifest: {
1586
1765
  id: '${weaver.id}',
1587
- name: '${weaver.name}',
1766
+ name: ${stringLiteral(weaver.name)},
1588
1767
  capabilities: [${quotedList(weaver.capabilities)}],
1589
1768
  },
1590
1769
  activate(ctx) {
@@ -1598,7 +1777,7 @@ function accessLine(weaver) {
1598
1777
  }
1599
1778
 
1600
1779
  // ../devkit/src/recipes/platform-version.ts
1601
- var PLATFORM_VERSION = "0.15.2";
1780
+ var PLATFORM_VERSION = "0.17.0";
1602
1781
 
1603
1782
  // ../devkit/src/recipes/readme-notes.ts
1604
1783
  var UNTAGGED_NOTE = [
@@ -1741,7 +1920,7 @@ function readmeFile(weaver) {
1741
1920
  "## After scaffolding",
1742
1921
  "",
1743
1922
  "- `src/lib/i18n/de.json` starts as a copy of the English strings \u2014 translate it.",
1744
- `- A scaffolded command defaults its shortcut to \`mod+shift+<first letter of the id>\` \u2014 two weavers whose ids share a first letter collide; pass \`--shortcut\` or edit the command.`,
1923
+ `- A scaffolded command defaults its shortcut to \`mod+shift+\` and the first letter of the id a browser leaves to the page (it keeps n, t, w and q for itself) \u2014 two weavers that land on the same letter collide; pass \`--shortcut\` or edit the command.`,
1745
1924
  ...asBullet(UNTAGGED_NOTE),
1746
1925
  ""
1747
1926
  ].join("\n");
@@ -1763,7 +1942,7 @@ export class ${weaver.className}AboutDialog {
1763
1942
  }
1764
1943
  function aboutDialogTemplateFile(weaver) {
1765
1944
  return `<div class="flex flex-col items-center gap-2 text-center">
1766
- <h2 class="text-lg font-semibold text-content">${weaver.name}</h2>
1945
+ <h2 class="text-lg font-semibold text-content">${markupText(weaver.name)}</h2>
1767
1946
  <span class="text-xs text-content-faint tabular-nums">v{{ host.version() }}</span>
1768
1947
  </div>
1769
1948
  `;
@@ -1820,7 +1999,7 @@ function viewTemplateFile(weaver) {
1820
1999
  </p>
1821
2000
  ` : "";
1822
2001
  return `<div class="mx-auto flex max-w-2xl flex-col gap-4 p-6">
1823
- <h2 class="text-lg font-semibold text-content">${weaver.name}</h2>
2002
+ <h2 class="text-lg font-semibold text-content">${markupText(weaver.name)}</h2>
1824
2003
  <p class="text-sm text-content-faint">
1825
2004
  Your new weaver surface. Register more surfaces, commands, rail items and menus on
1826
2005
  <code class="text-content">ctx</code> inside the plugin's
@@ -1933,7 +2112,7 @@ function pluginHtml(plugin) {
1933
2112
  <html lang="en">
1934
2113
  <head>
1935
2114
  <meta charset="utf-8" />
1936
- <title>${plugin.name} \u2014 frame plugin (logic)</title>
2115
+ <title>${markupText(plugin.name)} \u2014 frame plugin (logic)</title>
1937
2116
  </head>
1938
2117
  <body>
1939
2118
  <script src="/frame-kit/penpal.global.js"></script>
@@ -1954,10 +2133,10 @@ function pluginJs(plugin) {
1954
2133
  connection.promise
1955
2134
  .then(function (ctx) {
1956
2135
  return Promise.all([
1957
- ctx.toast({ message: '${plugin.name} ready', kind: 'success', timeoutMs: 4000 }),
2136
+ ctx.toast({ message: ${stringLiteral(`${plugin.name} ready`)}, kind: 'success', timeoutMs: 4000 }),
1958
2137
  ctx.registerSurface({
1959
2138
  id: '${plugin.id}.view',
1960
- title: '${plugin.name}',
2139
+ title: ${stringLiteral(plugin.name)},
1961
2140
  iframe: '/${plugin.id}/view.html',
1962
2141
  routable: { path: '${plugin.id}', titleIsLiteral: true },
1963
2142
  }),
@@ -1974,23 +2153,18 @@ function viewHtml(plugin) {
1974
2153
  <html lang="en">
1975
2154
  <head>
1976
2155
  <meta charset="utf-8" />
1977
- <title>${plugin.name}</title>
2156
+ <title>${markupText(plugin.name)}</title>
1978
2157
  <link rel="stylesheet" href="/frame-kit/lw-frame.css" />
1979
2158
  <style>
1980
- body {
1981
- margin: 0;
1982
- font-family: var(--lw-font-sans, system-ui, sans-serif);
1983
- color: var(--lw-content, #1f2937);
1984
- background: var(--lw-surface, transparent);
1985
- }
2159
+ body { margin: 0; }
1986
2160
  .wrap { max-width: 42rem; margin: 0 auto; padding: 1.5rem; }
1987
2161
  h1 { font-size: 1.125rem; font-weight: 600; }
1988
- p { color: var(--lw-content-faint, #6b7280); }
2162
+ p { color: var(--lw-content-faint); }
1989
2163
  </style>
1990
2164
  </head>
1991
2165
  <body>
1992
2166
  <div class="wrap">
1993
- <h1>${plugin.name}</h1>
2167
+ <h1>${markupText(plugin.name)}</h1>
1994
2168
  <p>
1995
2169
  Your sandboxed surface. It runs isolated in its own iframe, so its body can be built with
1996
2170
  any framework (React, Vue, Svelte, vanilla). The frame UI kit (served by the distribution
@@ -2002,16 +2176,23 @@ function viewHtml(plugin) {
2002
2176
  <script src="/frame-kit/penpal.global.js"></script>
2003
2177
  <script src="/frame-kit/lw-elements.global.js"></script>
2004
2178
  <script>
2005
- globalThis.Penpal.connect({
2179
+ const connection = globalThis.Penpal.connect({
2006
2180
  messenger: new globalThis.Penpal.WindowMessenger({
2007
2181
  remoteWindow: globalThis.parent,
2008
2182
  allowedOrigins: ['*'],
2009
2183
  }),
2010
- methods: {
2184
+ methods: globalThis.LwFrame.surfaceMethods({
2011
2185
  render: function (state) {
2012
2186
  globalThis.LwFrame.applySurfaceState(state);
2013
2187
  },
2014
- },
2188
+ stateChanged: function (key, value, loaded) {
2189
+ globalThis.LwFrame.state.apply(key, value, loaded);
2190
+ },
2191
+ }),
2192
+ });
2193
+ connection.promise.then(function (host) {
2194
+ globalThis.LwFrame.connectState(host);
2195
+ globalThis.LwFrame.connectToolbars(host);
2015
2196
  });
2016
2197
  </script>
2017
2198
  </body>
@@ -2025,24 +2206,25 @@ function readme(plugin) {
2025
2206
  `A framework-agnostic LoomWeaver plugin: it runs in an isolated \`<iframe sandbox>\` and`,
2026
2207
  `receives \`ctx\` over Penpal RPC through the same default-deny broker a trusted plugin uses.`,
2027
2208
  "",
2028
- "## Serve it + wire it into a distribution",
2209
+ "## How it is served and wired",
2029
2210
  "",
2030
- `1. Put these files under the distribution's static dir, e.g. \`public/${plugin.id}/\`. The plugin`,
2031
- ` references the **frame UI kit** (\`@loomweaver/frame-kit\`) at \`/frame-kit/\` \u2014`,
2032
- ` the distribution serves it via an assets glob (generated distributions already do):`,
2211
+ `The scaffold wires it into the distribution: it serves these files under \`/${plugin.id}/\` and the`,
2212
+ "**frame UI kit** (`@loomweaver/frame-kit`) under `/frame-kit/`, and registers and grants the",
2213
+ "plugin in the composition root:",
2033
2214
  "",
2034
- " ```jsonc",
2035
- ' { "input": "node_modules/@loomweaver/frame-kit/dist", "glob": "**", "output": "frame-kit" }',
2036
- " ```",
2037
- "",
2038
- `2. Register + grant it in the composition root:`,
2215
+ "```ts",
2216
+ `...provideFramePlugins({ id: '${plugin.id}', entryUrl: '/${plugin.id}/plugin.html', capabilities: ['contributions', 'ui'] }),`,
2217
+ `provideCapabilityGrants({ '${plugin.id}': ['contributions', 'ui'] }),`,
2218
+ "```",
2039
2219
  "",
2040
- " ```ts",
2041
- ` provideFramePlugins({ id: '${plugin.id}', entryUrl: '/${plugin.id}/plugin.html', capabilities: ['contributions', 'ui'] });`,
2042
- ` provideCapabilityGrants({ '${plugin.id}': ['contributions', 'ui'] });`,
2043
- " ```",
2220
+ "Where it was generated without a workspace to wire, those are the steps it named instead.",
2044
2221
  "",
2045
2222
  `The surface is routable at \`/${plugin.id}\`. Replace \`view.html\` with your own UI in any framework.`,
2223
+ "",
2224
+ "Keep its connection on `LwFrame.surfaceMethods`, `connectState` and `connectToolbars`: they let the",
2225
+ "workbench picture the surface, push the plugin's state and fill any `<lw-toolbar>` you place. Writing",
2226
+ 'the surface in TypeScript? Add `"types": ["@loomweaver/frame-kit"]` to your tsconfig and `LwFrame` is',
2227
+ "typed from the kit's `lw-frame.d.ts`.",
2046
2228
  ""
2047
2229
  ].join("\n");
2048
2230
  }
@@ -2395,7 +2577,7 @@ export const appConfig: ApplicationConfig = {
2395
2577
  provideCommandPaletteEntry(),
2396
2578
  provideQuickOpenEntry(),
2397
2579
  provideProductIdentity({
2398
- name: '${distribution.title}',
2580
+ name: ${stringLiteral(distribution.title)},
2399
2581
  tagline: 'Built on LoomWeaver',
2400
2582
  logoUrl: 'logo.svg',
2401
2583
  }),
@@ -2465,7 +2647,7 @@ function indexHtml(distribution) {
2465
2647
  <html lang="en">
2466
2648
  <head>
2467
2649
  <meta charset="utf-8" />
2468
- <title>${distribution.title}</title>
2650
+ <title>${markupText(distribution.title)}</title>
2469
2651
  <base href="/" />
2470
2652
  <meta
2471
2653
  http-equiv="Content-Security-Policy"
@@ -2835,8 +3017,8 @@ function resolveSettingsStoreInput(input) {
2835
3017
  function moduleFile2(store) {
2836
3018
  return `// A backend-backed settings store for the SETTINGS_STORE port. The platform
2837
3019
  // ships local defaults; the product persists settings against its own backend. Only genuine
2838
- // settings flow through this port \u2014 working state stays on WORKING_STATE_STORE. Wire it with:
2839
- // provideSettingsStore(new ${store.className}SettingsStore()).
3020
+ // settings flow through this port \u2014 working state stays on WORKING_STATE_STORE. The scaffold
3021
+ // provides it in the composition root: provideSettingsStore(new ${store.className}SettingsStore()).
2840
3022
  import { KeyValueStore } from '@loomweaver/shell';
2841
3023
 
2842
3024
  export class ${store.className}SettingsStore implements KeyValueStore {
@@ -2886,7 +3068,7 @@ function resolveThemeInput(input) {
2886
3068
  }
2887
3069
  function cssFile(theme2) {
2888
3070
  return `/* ${theme2.title} theme \u2014 overrides LoomWeaver's --lw-* design tokens.
2889
- Import it AFTER the shell theme in your distribution styles.css:
3071
+ The scaffold imports it into your distribution's entry stylesheet, after the shell's styles:
2890
3072
  @import '@loomweaver/shell/styles/theme.css';
2891
3073
  @import './themes/${theme2.name}.css';
2892
3074
  Declared in @layer lw-tenant-theme so it beats any plugin's ctx.contributeTheme
@@ -2925,8 +3107,8 @@ function cssFile(theme2) {
2925
3107
  }
2926
3108
  function bootstrapFile(theme2) {
2927
3109
  return `/* ${theme2.title} theme \u2014 maps LoomWeaver's --lw-* design tokens onto Bootstrap 5.3's --bs-*.
2928
- Import it AFTER the shell theme in your distribution styles.css, and import Bootstrap itself
2929
- INTO A LAYER \u2014 unlayered CSS outranks layered CSS whatever its specificity, so Bootstrap's
3110
+ The scaffold imports it into your distribution's entry stylesheet, after the shell's styles.
3111
+ Import Bootstrap itself INTO A LAYER \u2014 unlayered CSS outranks layered CSS whatever its specificity, so Bootstrap's
2930
3112
  Reboot (button { border-radius: 0 }) would otherwise beat our .lw-* component classes:
2931
3113
  @layer vendor;
2932
3114
  @import 'bootstrap/dist/css/bootstrap.css' layer(vendor);
@@ -3021,8 +3203,8 @@ function resolveLayoutInput(input) {
3021
3203
  return { name, propertyName: toCamelCase(name) };
3022
3204
  }
3023
3205
  function moduleFile3(layout2) {
3024
- return `// A base layout for the distribution. Pass it to provideLayout(${layout2.propertyName}Layout)
3025
- // in src/app/app.config.ts. Region ids are what contributions target \u2014 '${RAIL_REGION}' (rail) and '${STATUS_BAR_REGION}' (bar) match
3206
+ return `// A base layout for the distribution. The scaffold provides it in src/app/app.config.ts with
3207
+ // provideLayout(${layout2.propertyName}Layout), unless a layout is already provided there. Region ids are what contributions target \u2014 '${RAIL_REGION}' (rail) and '${STATUS_BAR_REGION}' (bar) match
3026
3208
  // the devkit weaver defaults, so a scaffolded weaver's rail + bar items land here.
3027
3209
  import { ShellLayout } from '@loomweaver/shell';
3028
3210
 
@@ -3332,18 +3514,70 @@ var authSourceScaffold = {
3332
3514
  default: false
3333
3515
  },
3334
3516
  APP_OPTION,
3335
- {
3336
- name: "directory",
3337
- type: "string",
3338
- description: "Where the files land, relative to the workspace root.",
3339
- workspaceOnly: true
3340
- }
3517
+ DIRECTORY_OPTION
3341
3518
  ],
3342
3519
  build: (values) => generate(authSource, authSourceInput(values)),
3343
3520
  amend: (values) => authSourceAmendments(authSourceInput(values), stringValue(values, "directory"))
3344
3521
  };
3345
3522
 
3523
+ // ../devkit/src/recipes/frame-plugin/amendments.ts
3524
+ var FRAME_PLUGIN_CAPABILITIES = ["contributions", "ui"];
3525
+ function framePluginAmendments(input, where) {
3526
+ if (where === void 0 || where === "") {
3527
+ return [];
3528
+ }
3529
+ const plugin = resolveFramePluginInput(input);
3530
+ const capabilities = quotedList(FRAME_PLUGIN_CAPABILITIES);
3531
+ const entryUrl = `/${plugin.id}/plugin.html`;
3532
+ return [
3533
+ {
3534
+ kind: "package",
3535
+ name: "@loomweaver/frame-kit",
3536
+ version: `^${PLATFORM_VERSION}`
3537
+ },
3538
+ {
3539
+ kind: "build-target",
3540
+ styles: [],
3541
+ assets: [
3542
+ {
3543
+ glob: "**/*",
3544
+ input: normalizeProjectRoot(where),
3545
+ from: "workspace",
3546
+ output: plugin.id,
3547
+ without: `without it nothing is served at /${plugin.id}/ and the plugin never loads`
3548
+ },
3549
+ {
3550
+ glob: "**/*",
3551
+ input: "node_modules/@loomweaver/frame-kit/dist",
3552
+ from: "workspace",
3553
+ output: "frame-kit",
3554
+ without: "without it the frame kit the plugin loads from /frame-kit/ is missing"
3555
+ }
3556
+ ]
3557
+ },
3558
+ {
3559
+ kind: "compose-provider",
3560
+ providers: [
3561
+ {
3562
+ line: `...provideFramePlugins({ id: '${plugin.id}', name: ${stringLiteral(plugin.name)}, entryUrl: '${entryUrl}', capabilities: [${capabilities}] }),`,
3563
+ shell: ["provideFramePlugins"],
3564
+ unless: `'${entryUrl}'`
3565
+ },
3566
+ {
3567
+ line: `provideCapabilityGrants({ ${grantKey(plugin.id)}: [${capabilities}] }),`,
3568
+ shell: ["provideCapabilityGrants"],
3569
+ unless: `${grantKey(plugin.id)}: [`
3570
+ }
3571
+ ],
3572
+ without: "Without them the plugin is never loaded, and loaded without its grant every call it makes is refused."
3573
+ }
3574
+ ];
3575
+ }
3576
+
3346
3577
  // ../devkit/src/recipes/frame-plugin/scaffold.ts
3578
+ function framePluginInput(values) {
3579
+ return { id: stringValue(values, "id") ?? "", name: stringValue(values, "name") };
3580
+ }
3347
3581
  var framePluginScaffold = {
3348
3582
  name: "frame-plugin",
3349
3583
  summary: "a framework-agnostic iframe plugin (Penpal + the frame UI kit)",
@@ -3360,14 +3594,37 @@ var framePluginScaffold = {
3360
3594
  type: "string",
3361
3595
  description: "Human-readable name. Defaults to a title-cased id."
3362
3596
  },
3363
- APP_OPTION
3597
+ APP_OPTION,
3598
+ DIRECTORY_OPTION
3364
3599
  ],
3365
- build: (values) => generate(framePlugin, {
3366
- id: stringValue(values, "id") ?? "",
3367
- name: stringValue(values, "name")
3368
- })
3600
+ build: (values) => generate(framePlugin, framePluginInput(values)),
3601
+ amend: (values) => framePluginAmendments(framePluginInput(values), stringValue(values, "directory"))
3369
3602
  };
3370
3603
 
3604
+ // ../devkit/src/recipes/layout/amendments.ts
3605
+ function layoutAmendments(input, where) {
3606
+ if (where === void 0 || where === "") {
3607
+ return [];
3608
+ }
3609
+ const layout2 = resolveLayoutInput(input);
3610
+ const symbol = `${layout2.propertyName}Layout`;
3611
+ return [
3612
+ {
3613
+ kind: "compose-provider",
3614
+ module: joinProjectPath(normalizeProjectRoot(where), `${layout2.name}-layout`),
3615
+ providers: [
3616
+ {
3617
+ line: `provideLayout(${symbol}),`,
3618
+ shell: ["provideLayout"],
3619
+ own: [symbol],
3620
+ unless: "provideLayout("
3621
+ }
3622
+ ],
3623
+ without: "Without it the workbench keeps the layout it has, and this one is never drawn."
3624
+ }
3625
+ ];
3626
+ }
3627
+
3371
3628
  // ../devkit/src/recipes/layout/scaffold.ts
3372
3629
  var layoutScaffold = {
3373
3630
  name: "layout",
@@ -3376,13 +3633,41 @@ var layoutScaffold = {
3376
3633
  {
3377
3634
  name: "name",
3378
3635
  type: "string",
3379
- description: "Layout name. Defaults to a base layout."
3636
+ description: "Layout name, kebab-case; the file is <name>-layout.ts.",
3637
+ pattern: KEBAB_ID_PATTERN,
3638
+ default: "base"
3380
3639
  },
3381
- APP_OPTION
3640
+ APP_OPTION,
3641
+ DIRECTORY_OPTION
3382
3642
  ],
3383
- build: (values) => generate(layout, { name: stringValue(values, "name") })
3643
+ build: (values) => generate(layout, { name: stringValue(values, "name") }),
3644
+ amend: (values) => layoutAmendments({ name: stringValue(values, "name") }, stringValue(values, "directory"))
3384
3645
  };
3385
3646
 
3647
+ // ../devkit/src/recipes/settings-store/amendments.ts
3648
+ function settingsStoreAmendments(input, where) {
3649
+ if (where === void 0 || where === "") {
3650
+ return [];
3651
+ }
3652
+ const store = resolveSettingsStoreInput(input);
3653
+ const symbol = `${store.className}SettingsStore`;
3654
+ return [
3655
+ {
3656
+ kind: "compose-provider",
3657
+ module: joinProjectPath(normalizeProjectRoot(where), `${store.name}-settings-store`),
3658
+ providers: [
3659
+ {
3660
+ line: `provideSettingsStore(new ${symbol}()),`,
3661
+ shell: ["provideSettingsStore"],
3662
+ own: [symbol],
3663
+ unless: "provideSettingsStore("
3664
+ }
3665
+ ],
3666
+ without: "Without it settings stay in the browser's local storage and never reach your API."
3667
+ }
3668
+ ];
3669
+ }
3670
+
3386
3671
  // ../devkit/src/recipes/settings-store/scaffold.ts
3387
3672
  var settingsStoreScaffold = {
3388
3673
  name: "settings-store",
@@ -3395,12 +3680,40 @@ var settingsStoreScaffold = {
3395
3680
  required: true,
3396
3681
  pattern: KEBAB_ID_PATTERN
3397
3682
  },
3398
- APP_OPTION
3683
+ APP_OPTION,
3684
+ DIRECTORY_OPTION
3399
3685
  ],
3400
- build: (values) => generate(settingsStore, { name: stringValue(values, "name") ?? "" })
3686
+ build: (values) => generate(settingsStore, { name: stringValue(values, "name") ?? "" }),
3687
+ amend: (values) => settingsStoreAmendments(
3688
+ { name: stringValue(values, "name") ?? "" },
3689
+ stringValue(values, "directory")
3690
+ )
3401
3691
  };
3402
3692
 
3693
+ // ../devkit/src/recipes/theme/amendments.ts
3694
+ var SHELL_STYLES = "@loomweaver/shell/styles/";
3695
+ function themeAmendments(input, where) {
3696
+ if (where === void 0 || where === "") {
3697
+ return [];
3698
+ }
3699
+ const theme2 = resolveThemeInput(input);
3700
+ return [
3701
+ {
3702
+ kind: "stylesheet-import",
3703
+ file: joinProjectPath(normalizeProjectRoot(where), `${theme2.name}.css`),
3704
+ after: SHELL_STYLES,
3705
+ without: "Without it the theme is never loaded and changes nothing."
3706
+ }
3707
+ ];
3708
+ }
3709
+
3403
3710
  // ../devkit/src/recipes/theme/scaffold.ts
3711
+ function themeInput(values) {
3712
+ return {
3713
+ name: stringValue(values, "name") ?? "",
3714
+ preset: stringValue(values, "preset") ?? "literal"
3715
+ };
3716
+ }
3404
3717
  var themeScaffold = {
3405
3718
  name: "theme",
3406
3719
  summary: "a token-override stylesheet in @layer lw-tenant-theme",
@@ -3419,12 +3732,11 @@ var themeScaffold = {
3419
3732
  choices: THEME_PRESETS,
3420
3733
  default: "literal"
3421
3734
  },
3422
- APP_OPTION
3735
+ APP_OPTION,
3736
+ DIRECTORY_OPTION
3423
3737
  ],
3424
- build: (values) => generate(theme, {
3425
- name: stringValue(values, "name") ?? "",
3426
- preset: stringValue(values, "preset") ?? "literal"
3427
- })
3738
+ build: (values) => generate(theme, themeInput(values)),
3739
+ amend: (values) => themeAmendments(themeInput(values), stringValue(values, "directory"))
3428
3740
  };
3429
3741
 
3430
3742
  // ../devkit/src/lib/scaffolds/scaffolds.ts
@@ -3493,30 +3805,11 @@ function validateI18nParity(bundles) {
3493
3805
  return findings;
3494
3806
  }
3495
3807
 
3496
- // ../devkit/src/lib/validate/catalog.ts
3497
- var CATALOG_ENTRY_KEYS = [
3498
- "id",
3499
- "name",
3500
- "entryUrl",
3501
- "capabilities",
3502
- "version",
3503
- "iconUrl",
3504
- "description",
3505
- "icon",
3506
- "category",
3507
- "author",
3508
- "downloads",
3509
- "updated",
3510
- "repository",
3511
- "readmeUrl"
3512
- ];
3808
+ // ../devkit/src/lib/validate/catalog-urls.ts
3513
3809
  var SAME_ORIGIN_FIELDS = ["entryUrl", "iconUrl", "readmeUrl"];
3514
3810
  function pathOf(index, field) {
3515
3811
  return field ? `catalog[${index}].${field}` : `catalog[${index}]`;
3516
3812
  }
3517
- function isPlainObject(raw) {
3518
- return typeof raw === "object" && raw !== null && !Array.isArray(raw);
3519
- }
3520
3813
  function requiredUrlFinding(value, index, field) {
3521
3814
  if (typeof value !== "string" || value.length === 0) {
3522
3815
  return {
@@ -3540,6 +3833,14 @@ function optionalUrlFinding(value, index, field) {
3540
3833
  return schemeFinding(value, index, field, "ignores the field");
3541
3834
  }
3542
3835
  function schemeFinding(value, index, field, refusal) {
3836
+ if (value.startsWith("//")) {
3837
+ return {
3838
+ level: "error",
3839
+ code: "catalog.url.foreign",
3840
+ message: `${pathOf(index, field)} is protocol-relative, so it names another host. The host accepts same-origin URLs only and ${refusal}.`,
3841
+ path: pathOf(index, field)
3842
+ };
3843
+ }
3543
3844
  const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
3544
3845
  if (scheme && scheme !== "http" && scheme !== "https") {
3545
3846
  return {
@@ -3559,6 +3860,56 @@ function schemeFinding(value, index, field, refusal) {
3559
3860
  }
3560
3861
  return void 0;
3561
3862
  }
3863
+ function validateSameOriginUrls(raw, index) {
3864
+ const findings = [];
3865
+ for (const field of SAME_ORIGIN_FIELDS) {
3866
+ const finding = sameOriginUrlFinding(raw, index, field);
3867
+ if (finding) {
3868
+ findings.push(finding);
3869
+ }
3870
+ }
3871
+ return findings;
3872
+ }
3873
+ function sameOriginUrlFinding(raw, index, field) {
3874
+ if (field === "entryUrl") {
3875
+ return requiredUrlFinding(raw[field], index, field);
3876
+ }
3877
+ return raw[field] === void 0 ? void 0 : optionalUrlFinding(raw[field], index, field);
3878
+ }
3879
+ function isHttpUrl(raw) {
3880
+ if (typeof raw !== "string") {
3881
+ return false;
3882
+ }
3883
+ try {
3884
+ const url = new URL(raw);
3885
+ return url.protocol === "http:" || url.protocol === "https:";
3886
+ } catch {
3887
+ return false;
3888
+ }
3889
+ }
3890
+
3891
+ // ../devkit/src/lib/validate/catalog.ts
3892
+ var CATALOG_ENTRY_KEYS = [
3893
+ "id",
3894
+ "name",
3895
+ "entryUrl",
3896
+ "capabilities",
3897
+ "version",
3898
+ "iconUrl",
3899
+ "description",
3900
+ "icon",
3901
+ "category",
3902
+ "author",
3903
+ "downloads",
3904
+ "updated",
3905
+ "repository",
3906
+ "readmeUrl",
3907
+ "level",
3908
+ "deployed"
3909
+ ];
3910
+ function isPlainObject(raw) {
3911
+ return typeof raw === "object" && raw !== null && !Array.isArray(raw);
3912
+ }
3562
3913
  function validateCapabilities2(value, index, known) {
3563
3914
  if (value === void 0) {
3564
3915
  return [
@@ -3623,22 +3974,6 @@ function validateId(raw, index) {
3623
3974
  }
3624
3975
  ];
3625
3976
  }
3626
- function validateSameOriginUrls(raw, index) {
3627
- const findings = [];
3628
- for (const field of SAME_ORIGIN_FIELDS) {
3629
- const finding = sameOriginUrlFinding(raw, index, field);
3630
- if (finding) {
3631
- findings.push(finding);
3632
- }
3633
- }
3634
- return findings;
3635
- }
3636
- function sameOriginUrlFinding(raw, index, field) {
3637
- if (field === "entryUrl") {
3638
- return requiredUrlFinding(raw[field], index, field);
3639
- }
3640
- return raw[field] === void 0 ? void 0 : optionalUrlFinding(raw[field], index, field);
3641
- }
3642
3977
  function validateNameAndVersion(raw, index) {
3643
3978
  const findings = [];
3644
3979
  if (raw["name"] === void 0) {
@@ -3666,8 +4001,28 @@ function validateNameAndVersion(raw, index) {
3666
4001
  }
3667
4002
  return findings;
3668
4003
  }
4004
+ var TEXT_FIELDS = [
4005
+ { field: "description", loss: "the store shows no description" },
4006
+ { field: "icon", loss: "the install dialog shows the default icon" },
4007
+ { field: "category", loss: "the entry carries no category badge and the search cannot match one" },
4008
+ { field: "author", loss: "the store names no author" },
4009
+ {
4010
+ field: "version",
4011
+ loss: "update detection has nothing to compare, so the store can never offer an update"
4012
+ }
4013
+ ];
4014
+ function validateTextFields(raw, index) {
4015
+ return TEXT_FIELDS.filter(
4016
+ ({ field }) => raw[field] !== void 0 && (typeof raw[field] !== "string" || raw[field] === "")
4017
+ ).map(({ field, loss }) => ({
4018
+ level: "warning",
4019
+ code: "catalog.text",
4020
+ message: `${pathOf(index, field)} is not a non-empty string, so the host drops it and ${loss}.`,
4021
+ path: pathOf(index, field)
4022
+ }));
4023
+ }
3669
4024
  function validateEntryMetadata(raw, index) {
3670
- const findings = [];
4025
+ const findings = [...validateTextFields(raw, index)];
3671
4026
  if (raw["downloads"] !== void 0 && (typeof raw["downloads"] !== "number" || raw["downloads"] < 0)) {
3672
4027
  findings.push({
3673
4028
  level: "warning",
@@ -3676,7 +4031,14 @@ function validateEntryMetadata(raw, index) {
3676
4031
  path: pathOf(index, "downloads")
3677
4032
  });
3678
4033
  }
3679
- if (raw["updated"] !== void 0 && !isRenderableDate(raw["updated"])) {
4034
+ if (raw["updated"] !== void 0 && (typeof raw["updated"] !== "string" || raw["updated"] === "")) {
4035
+ findings.push({
4036
+ level: "warning",
4037
+ code: "catalog.updated",
4038
+ message: `${pathOf(index, "updated")} is not a non-empty string, so the host drops it and the store shows no date.`,
4039
+ path: pathOf(index, "updated")
4040
+ });
4041
+ } else if (raw["updated"] !== void 0 && !isRenderableDate(raw["updated"])) {
3680
4042
  findings.push({
3681
4043
  level: "warning",
3682
4044
  code: "catalog.updated",
@@ -3684,6 +4046,7 @@ function validateEntryMetadata(raw, index) {
3684
4046
  path: pathOf(index, "updated")
3685
4047
  });
3686
4048
  }
4049
+ findings.push(...validatePlacement(raw, index));
3687
4050
  if (raw["repository"] !== void 0 && !isHttpUrl(raw["repository"])) {
3688
4051
  findings.push({
3689
4052
  level: "warning",
@@ -3694,6 +4057,26 @@ function validateEntryMetadata(raw, index) {
3694
4057
  }
3695
4058
  return findings;
3696
4059
  }
4060
+ function validatePlacement(raw, index) {
4061
+ const findings = [];
4062
+ if (raw["deployed"] !== void 0 && typeof raw["deployed"] !== "boolean") {
4063
+ findings.push({
4064
+ level: "warning",
4065
+ code: "catalog.deployed",
4066
+ message: `${pathOf(index, "deployed")} must be true or false. The host deploys an entry only when it is exactly true, so this one is merely offered.`,
4067
+ path: pathOf(index, "deployed")
4068
+ });
4069
+ }
4070
+ if (raw["level"] !== void 0 && raw["level"] !== "embedded" && raw["level"] !== "isolated") {
4071
+ findings.push({
4072
+ level: "warning",
4073
+ code: "catalog.level",
4074
+ message: `${pathOf(index, "level")} must be "embedded" or "isolated". The host ignores anything else and runs the plugin at the level the distribution chooses.`,
4075
+ path: pathOf(index, "level")
4076
+ });
4077
+ }
4078
+ return findings;
4079
+ }
3697
4080
  function validateEntryKeys(raw, index) {
3698
4081
  const findings = [];
3699
4082
  for (const key of Object.keys(raw)) {
@@ -3708,16 +4091,11 @@ function validateEntryKeys(raw, index) {
3708
4091
  }
3709
4092
  return findings;
3710
4093
  }
3711
- function isHttpUrl(raw) {
3712
- if (typeof raw !== "string") {
3713
- return false;
3714
- }
3715
- try {
3716
- const url = new URL(raw);
3717
- return url.protocol === "http:" || url.protocol === "https:";
3718
- } catch {
3719
- return false;
3720
- }
4094
+ function dropsEntry(findings, index) {
4095
+ const fatal = /* @__PURE__ */ new Set([pathOf(index), pathOf(index, "id"), pathOf(index, "entryUrl")]);
4096
+ return findings.some(
4097
+ (finding) => finding.level === "error" && finding.path !== void 0 && fatal.has(finding.path)
4098
+ );
3721
4099
  }
3722
4100
  function isRenderableDate(raw) {
3723
4101
  return typeof raw === "string" && !Number.isNaN(new Date(raw).getTime());
@@ -3734,20 +4112,24 @@ function validateCatalog(catalog, known = KNOWN_CAPABILITIES) {
3734
4112
  ];
3735
4113
  }
3736
4114
  const findings = [];
3737
- const seen = /* @__PURE__ */ new Set();
4115
+ const seen = /* @__PURE__ */ new Map();
3738
4116
  for (const [index, entry] of catalog.entries()) {
3739
- findings.push(...validateEntry(entry, index, known));
3740
- const id = isPlainObject(entry) ? entry["id"] : void 0;
3741
- if (typeof id === "string" && id.length > 0) {
3742
- if (seen.has(id)) {
3743
- findings.push({
3744
- level: "warning",
3745
- code: "catalog.id.duplicate",
3746
- message: `${pathOf(index, "id")} repeats "${id}". The host keeps the first entry with an id and drops the rest.`,
3747
- path: pathOf(index, "id")
3748
- });
3749
- }
3750
- seen.add(id);
4117
+ const own = validateEntry(entry, index, known);
4118
+ findings.push(...own);
4119
+ if (!isPlainObject(entry) || dropsEntry(own, index)) {
4120
+ continue;
4121
+ }
4122
+ const id = entry["id"];
4123
+ const first = seen.get(id);
4124
+ if (first === void 0) {
4125
+ seen.set(id, index);
4126
+ } else {
4127
+ findings.push({
4128
+ level: "warning",
4129
+ code: "catalog.id.duplicate",
4130
+ message: `${pathOf(index, "id")} repeats "${id}" from ${pathOf(first)}. The host keeps the first usable entry with an id and drops this one.`,
4131
+ path: pathOf(index, "id")
4132
+ });
3751
4133
  }
3752
4134
  }
3753
4135
  return findings;
@@ -3758,26 +4140,100 @@ var RUNTIME_NOTE = "Grants, access and the window decide what is finally offered
3758
4140
  function validateCommands(sources, ts) {
3759
4141
  const findings = [];
3760
4142
  for (const source of sources) {
3761
- const file = ts.createSourceFile(source.path, source.text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
4143
+ const file = ts.createSourceFile(source.path, source.text, ts.ScriptTarget.Latest, true, scriptKind(ts, source.path));
4144
+ const constants = stringConstants(ts, file);
3762
4145
  for (const call of registerCommandCalls(ts, file)) {
3763
- findings.push(...findingsFor(readRegistration(ts, file, call)));
4146
+ findings.push(...findingsFor(readRegistration(ts, file, call, constants)));
3764
4147
  }
3765
4148
  }
3766
4149
  findings.push({ level: "info", code: "commands.runtime", message: RUNTIME_NOTE });
3767
4150
  return findings;
3768
4151
  }
4152
+ var REGISTER = "registerCommand";
4153
+ function scriptKind(ts, path) {
4154
+ if (/\.[cm]?js$/.test(path)) {
4155
+ return ts.ScriptKind.JS;
4156
+ }
4157
+ if (path.endsWith(".jsx")) {
4158
+ return ts.ScriptKind.JSX;
4159
+ }
4160
+ return path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
4161
+ }
4162
+ function walk(ts, file, visit) {
4163
+ const step = (node) => {
4164
+ visit(node);
4165
+ ts.forEachChild(node, step);
4166
+ };
4167
+ step(file);
4168
+ }
4169
+ function stringConstants(ts, file) {
4170
+ const constants = /* @__PURE__ */ new Map();
4171
+ walk(ts, file, (node) => {
4172
+ if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.Const) !== 0) {
4173
+ for (const declaration of node.declarations) {
4174
+ const value = unwrapped(ts, declaration.initializer);
4175
+ if (ts.isIdentifier(declaration.name) && value && ts.isStringLiteralLike(value)) {
4176
+ constants.set(declaration.name.text, value.text);
4177
+ }
4178
+ }
4179
+ }
4180
+ });
4181
+ return constants;
4182
+ }
4183
+ function registerAliases(ts, file) {
4184
+ const aliases = /* @__PURE__ */ new Set([REGISTER]);
4185
+ walk(ts, file, (node) => {
4186
+ if (ts.isBindingElement(node) && ts.isIdentifier(node.name)) {
4187
+ const property = node.propertyName ?? node.name;
4188
+ if (ts.isIdentifier(property) && property.text === REGISTER) {
4189
+ aliases.add(node.name.text);
4190
+ }
4191
+ }
4192
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && namesRegister(ts, node.initializer)) {
4193
+ aliases.add(node.name.text);
4194
+ }
4195
+ });
4196
+ return aliases;
4197
+ }
4198
+ function namesRegister(ts, value) {
4199
+ let target = unwrapped(ts, value);
4200
+ while (target && ts.isCallExpression(target) && ts.isPropertyAccessExpression(target.expression) && target.expression.name.text === "bind") {
4201
+ target = unwrapped(ts, target.expression.expression);
4202
+ }
4203
+ return !!target && ts.isPropertyAccessExpression(target) && target.name.text === REGISTER;
4204
+ }
3769
4205
  function registerCommandCalls(ts, file) {
4206
+ const aliases = registerAliases(ts, file);
3770
4207
  const calls = [];
3771
- const visit = (node) => {
3772
- if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "registerCommand") {
4208
+ walk(ts, file, (node) => {
4209
+ if (!ts.isCallExpression(node)) {
4210
+ return;
4211
+ }
4212
+ const callee = node.expression;
4213
+ if (ts.isPropertyAccessExpression(callee) && callee.name.text === REGISTER || ts.isIdentifier(callee) && aliases.has(callee.text)) {
3773
4214
  calls.push(node);
3774
4215
  }
3775
- ts.forEachChild(node, visit);
3776
- };
3777
- visit(file);
4216
+ });
3778
4217
  return calls;
3779
4218
  }
3780
- function readRegistration(ts, file, call) {
4219
+ function stringOf(ts, value, constants) {
4220
+ const node = unwrapped(ts, value);
4221
+ if (!node) {
4222
+ return void 0;
4223
+ }
4224
+ if (ts.isStringLiteralLike(node)) {
4225
+ return node.text;
4226
+ }
4227
+ return ts.isIdentifier(node) ? constants.get(node.text) : void 0;
4228
+ }
4229
+ function isDeclared(ts, value) {
4230
+ const node = unwrapped(ts, value);
4231
+ if (!node) {
4232
+ return false;
4233
+ }
4234
+ return !(ts.isIdentifier(node) && node.text === "undefined" || ts.isVoidExpression(node));
4235
+ }
4236
+ function readRegistration(ts, file, call, constants) {
3781
4237
  const line = file.getLineAndCharacterOfPosition(call.getStart(file)).line + 1;
3782
4238
  const at = `${file.fileName}:${line}`;
3783
4239
  const literal = call.arguments[0];
@@ -3792,31 +4248,28 @@ function readRegistration(ts, file, call) {
3792
4248
  }
3793
4249
  if (ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) {
3794
4250
  properties.set(property.name.text, property.initializer);
4251
+ } else if (ts.isShorthandPropertyAssignment(property)) {
4252
+ properties.set(property.name.text, property.name);
3795
4253
  } else if (ts.isMethodDeclaration(property) && ts.isIdentifier(property.name)) {
3796
4254
  properties.set(property.name.text, property);
3797
4255
  }
3798
4256
  }
3799
- const id = properties.get("id");
3800
- if (!id || !ts.isStringLiteral(id)) {
3801
- return { ...empty, unreadable: "its id is not a string literal" };
4257
+ const id = stringOf(ts, properties.get("id"), constants);
4258
+ if (id === void 0) {
4259
+ return { ...empty, unreadable: "its id is neither a string literal nor a string constant declared in the file" };
3802
4260
  }
3803
- const callable = properties.get("callable");
3804
- const consent = properties.get("agentConsent");
4261
+ const consent = stringOf(ts, properties.get("agentConsent"), constants);
3805
4262
  return {
3806
4263
  at,
3807
- id: id.text,
3808
- callable: callable?.kind === ts.SyntaxKind.TrueKeyword,
3809
- described: properties.has("description"),
3810
- answers: properties.has("answers"),
4264
+ id,
4265
+ callable: unwrapped(ts, properties.get("callable"))?.kind === ts.SyntaxKind.TrueKeyword,
4266
+ described: isDeclared(ts, properties.get("description")),
4267
+ answers: isDeclared(ts, properties.get("answers")),
3811
4268
  returnsValue: returnsValue(ts, properties.get("run")),
3812
- arguments: readArguments(ts, properties.get("arguments")),
3813
- ...consentOf(ts, consent)
4269
+ arguments: readArguments(ts, properties.get("arguments"), constants),
4270
+ ...consent !== void 0 && { agentConsent: consent }
3814
4271
  };
3815
4272
  }
3816
- function consentOf(ts, declared) {
3817
- const value = unwrapped(ts, declared);
3818
- return value && ts.isStringLiteral(value) ? { agentConsent: value.text } : {};
3819
- }
3820
4273
  function unwrapped(ts, value) {
3821
4274
  if (!value) {
3822
4275
  return void 0;
@@ -3845,7 +4298,8 @@ function consentLine(consent) {
3845
4298
  }
3846
4299
  return CONSENT_LINES.get(consent) ?? `declares "${consent}" about an agent's word, which is none of ${STATED_CONSENTS.join(", ")}, so nothing reads it.`;
3847
4300
  }
3848
- function readArguments(ts, value) {
4301
+ function readArguments(ts, declared, constants) {
4302
+ const value = unwrapped(ts, declared);
3849
4303
  if (!value || !ts.isArrayLiteralExpression(value)) {
3850
4304
  return [];
3851
4305
  }
@@ -3859,17 +4313,17 @@ function readArguments(ts, value) {
3859
4313
  if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) {
3860
4314
  continue;
3861
4315
  }
3862
- if (property.name.text === "name" && ts.isStringLiteral(property.initializer)) {
3863
- name = property.initializer.text;
3864
- }
3865
- if (property.name.text === "description") {
3866
- described = true;
4316
+ if (property.name.text === "name") {
4317
+ name = stringOf(ts, property.initializer, constants) ?? name;
4318
+ } else if (property.name.text === "description") {
4319
+ described = isDeclared(ts, property.initializer);
3867
4320
  }
3868
4321
  }
3869
4322
  return { name, described };
3870
4323
  });
3871
4324
  }
3872
- function returnsValue(ts, run2) {
4325
+ function returnsValue(ts, declared) {
4326
+ const run2 = unwrapped(ts, declared);
3873
4327
  if (!run2) {
3874
4328
  return false;
3875
4329
  }
@@ -3979,32 +4433,20 @@ import { existsSync, readFileSync } from "node:fs";
3979
4433
  import { dirname, relative, resolve as resolve2, sep } from "node:path";
3980
4434
 
3981
4435
  // src/lib/angular-config.ts
3982
- function asObject2(value) {
3983
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
3984
- }
3985
4436
  function buildProjects(config) {
3986
- const projects = asObject2(asObject2(config)?.["projects"]) ?? {};
4437
+ const projects = asObject(asObject(config)?.["projects"]) ?? {};
3987
4438
  return Object.entries(projects).filter(([, project]) => buildTargetIn(project) !== void 0).map(([name, project]) => ({
3988
4439
  name,
3989
- root: normaliseRoot(asObject2(project)?.["root"]),
3990
- prefix: declaredPrefix(asObject2(project)?.["prefix"])
4440
+ root: normaliseRoot(asObject(project)?.["root"]),
4441
+ prefix: declaredPrefix(asObject(project)?.["prefix"])
3991
4442
  }));
3992
4443
  }
3993
4444
  function buildTargetOf(config, project) {
3994
- return buildTargetIn(asObject2(asObject2(config)?.["projects"])?.[project]);
3995
- }
3996
- function entryStylesheetOf(target) {
3997
- const styles = asObject2(asObject2(target)?.["options"])?.["styles"];
3998
- if (!Array.isArray(styles)) {
3999
- return void 0;
4000
- }
4001
- return styles.map((style) => typeof style === "string" ? style : asObject2(style)?.["input"]).find(
4002
- (input) => typeof input === "string" && input.endsWith(".css")
4003
- );
4445
+ return buildTargetIn(asObject(asObject(config)?.["projects"])?.[project]);
4004
4446
  }
4005
4447
  function buildTargetIn(project) {
4006
4448
  for (const key of ["architect", "targets"]) {
4007
- const targets = asObject2(asObject2(project)?.[key]);
4449
+ const targets = asObject(asObject(project)?.[key]);
4008
4450
  if (targets?.["build"] !== void 0) {
4009
4451
  return {
4010
4452
  value: targets["build"],
@@ -4247,7 +4689,7 @@ import { basename, join, relative as relative3, sep as sep2 } from "node:path";
4247
4689
  var SKIPPED = /* @__PURE__ */ new Set(["node_modules", "dist", "tmp", "coverage"]);
4248
4690
  function nxApplications(workspaceRoot) {
4249
4691
  const found = [];
4250
- const walk = (folder) => {
4692
+ const walk2 = (folder) => {
4251
4693
  const project = join(folder, "project.json");
4252
4694
  if (existsSync2(project)) {
4253
4695
  const config = readJsonFile(project);
@@ -4263,10 +4705,10 @@ function nxApplications(workspaceRoot) {
4263
4705
  if (!entry.isDirectory() || SKIPPED.has(entry.name) || entry.name.startsWith(".")) {
4264
4706
  continue;
4265
4707
  }
4266
- walk(join(folder, entry.name));
4708
+ walk2(join(folder, entry.name));
4267
4709
  }
4268
4710
  };
4269
- walk(workspaceRoot);
4711
+ walk2(workspaceRoot);
4270
4712
  return found.toSorted((a, b) => a.name.localeCompare(b.name));
4271
4713
  }
4272
4714
 
@@ -4328,10 +4770,12 @@ function runScriptCommand(manager, script) {
4328
4770
  }
4329
4771
 
4330
4772
  // src/lib/init/init-plan.ts
4331
- var RUNTIME_PACKAGES = [
4773
+ var PLATFORM_PACKAGES = [
4332
4774
  "@loomweaver/shell",
4333
4775
  "@loomweaver/plugin-sdk",
4334
- "@loomweaver/frame-kit",
4776
+ "@loomweaver/frame-kit"
4777
+ ];
4778
+ var PEER_PACKAGES = [
4335
4779
  "@angular/cdk",
4336
4780
  "@jsverse/transloco",
4337
4781
  "@ng-icons/heroicons"
@@ -4358,6 +4802,12 @@ function planInit(args, deps) {
4358
4802
  const app = workspace.kind === "nx" ? chooseApplication(workspace.root, stringFlag(args, "app")) : void 0;
4359
4803
  const name = app?.name ?? packageNameToId(manifest2.name ?? basename2(workspace.root));
4360
4804
  const styles = stringFlag(args, "styles") ?? "tailwind";
4805
+ const title = stringFlag(args, "title") ?? toTitleCase(name);
4806
+ const weaver = weaverId(args);
4807
+ resolveDistributionInput({ name, title, styles });
4808
+ if (weaver !== void 0) {
4809
+ resolveWeaverInput({ id: weaver });
4810
+ }
4361
4811
  const declared = { ...manifest2.dependencies, ...manifest2.devDependencies };
4362
4812
  const missing = (names) => names.filter((candidate) => !Object.hasOwn(declared, candidate));
4363
4813
  return {
@@ -4365,16 +4815,17 @@ function planInit(args, deps) {
4365
4815
  manager: override ?? detected.manager,
4366
4816
  lockfile: override ? void 0 : detected.lockfile,
4367
4817
  name,
4368
- title: stringFlag(args, "title") ?? toTitleCase(name),
4818
+ title,
4369
4819
  styles,
4370
- weaver: weaverId(args),
4820
+ weaver,
4371
4821
  runtime: [
4372
- ...missing(RUNTIME_PACKAGES),
4822
+ ...missing(PLATFORM_PACKAGES).map((pkg) => platformSpec(pkg, deps.version)),
4823
+ ...missing(PEER_PACKAGES),
4373
4824
  ...Object.hasOwn(declared, SERVICE_WORKER) ? [] : [serviceWorkerSpec(workspace.root, manifest2)]
4374
4825
  ],
4375
4826
  dev: [
4376
4827
  ...styles === "tailwind" ? missing(STYLE_PACKAGES) : [],
4377
- ...workspace.kind === "nx" && !Object.hasOwn(declared, NX_COLLECTION) ? [collectionSpec(deps.version)] : []
4828
+ ...workspace.kind === "nx" && !Object.hasOwn(declared, NX_COLLECTION) ? [platformSpec(NX_COLLECTION, deps.version)] : []
4378
4829
  ],
4379
4830
  app,
4380
4831
  dryRun: boolFlag(args, "dry-run") === true
@@ -4430,8 +4881,8 @@ function serviceWorkerSpec(root, manifest2) {
4430
4881
  const declared = manifest2.dependencies?.["@angular/core"];
4431
4882
  return declared ? `${SERVICE_WORKER}@${declared}` : SERVICE_WORKER;
4432
4883
  }
4433
- function collectionSpec(version) {
4434
- return version === UNBUNDLED_VERSION ? `${NX_COLLECTION}@latest` : `${NX_COLLECTION}@${version}`;
4884
+ function platformSpec(name, version) {
4885
+ return `${name}@${version === UNBUNDLED_VERSION ? "latest" : version}`;
4435
4886
  }
4436
4887
  function packageNameToId(value) {
4437
4888
  const unscoped = value.includes("/") ? value.slice(value.lastIndexOf("/") + 1) : value;
@@ -4673,7 +5124,7 @@ var AmendLog = class {
4673
5124
 
4674
5125
  // src/lib/scaffold/project-wiring.ts
4675
5126
  import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
4676
- import { dirname as dirname2, posix, resolve as resolve6 } from "node:path";
5127
+ import { dirname as dirname2, posix as posix4, resolve as resolve6 } from "node:path";
4677
5128
  var ProjectWiring = class {
4678
5129
  constructor(workspace, target, log) {
4679
5130
  this.workspace = workspace;
@@ -4686,6 +5137,7 @@ var ProjectWiring = class {
4686
5137
  configAdded = [];
4687
5138
  config;
4688
5139
  projectResolved = false;
5140
+ pendingCss = /* @__PURE__ */ new Map();
4689
5141
  project;
4690
5142
  plan(amendment) {
4691
5143
  const project = this.resolveProject();
@@ -4701,8 +5153,16 @@ var ProjectWiring = class {
4701
5153
  this.planStylesheetSource(amendment, project);
4702
5154
  return;
4703
5155
  }
5156
+ case "stylesheet-import": {
5157
+ this.planStylesheetImport(amendment, project);
5158
+ return;
5159
+ }
4704
5160
  case "compose-plugin": {
4705
5161
  this.planComposePlugin(amendment, project);
5162
+ return;
5163
+ }
5164
+ case "compose-provider": {
5165
+ this.planComposeProviders(amendment, project);
4706
5166
  }
4707
5167
  }
4708
5168
  }
@@ -4745,7 +5205,7 @@ var ProjectWiring = class {
4745
5205
  if (!usesTailwind(css)) {
4746
5206
  return;
4747
5207
  }
4748
- const source = posix.relative(this.log.displayName(dirname2(entry)), amendment.sourceRoot) || ".";
5208
+ const source = posix4.relative(this.log.displayName(dirname2(entry)), amendment.sourceRoot) || ".";
4749
5209
  const next = ensureStylesheetSource(css, source);
4750
5210
  if (next === css) {
4751
5211
  return;
@@ -4780,9 +5240,58 @@ var ProjectWiring = class {
4780
5240
  [
4781
5241
  `${amendment.symbol}, its translations and its capability grants`,
4782
5242
  ...(amendment.providers ?? []).filter((provider) => !result.kept.includes(provider.line)).map((provider) => provider.line.replace(/,$/, "")),
4783
- ...result.kept.map(
4784
- (line) => `kept the ${line.split("(", 1)[0]} already there instead of ${line.replace(/,$/, "")}`
4785
- )
5243
+ ...result.kept.map((line) => keptNote(line))
5244
+ ],
5245
+ result.source
5246
+ );
5247
+ }
5248
+ planStylesheetImport(amendment, project) {
5249
+ const entry = this.entryStylesheet(project);
5250
+ if (!entry || !existsSync6(entry)) {
5251
+ this.log.note(
5252
+ `No entry stylesheet is wired for ${project.name}, so add it yourself: ${describeAmendment(amendment)}`
5253
+ );
5254
+ return;
5255
+ }
5256
+ const css = this.pendingCss.get(entry) ?? readFileSync4(entry, "utf8");
5257
+ const displayed = this.log.displayName(entry);
5258
+ const result = ensureStylesheetImport(
5259
+ css,
5260
+ importSpecifier(displayed, amendment.file),
5261
+ amendment.after
5262
+ );
5263
+ if (!result.anchored) {
5264
+ this.log.note(
5265
+ `${displayed} does not import ${amendment.after}, so ${amendment.file} was imported after its last import instead; check that it still comes after the shell's styles.`
5266
+ );
5267
+ }
5268
+ if (result.css === css) {
5269
+ return;
5270
+ }
5271
+ this.pendingCss.set(entry, result.css);
5272
+ this.log.plan(entry, [`@import '${importSpecifier(displayed, amendment.file)}'`], result.css);
5273
+ }
5274
+ planComposeProviders(amendment, project) {
5275
+ const root = resolve6(this.workspace.root, project.root, "src/app/app.config.ts");
5276
+ const importPath = moduleImport(this.log.displayName(dirname2(root)), amendment.module);
5277
+ const source = existsSync6(root) ? readFileSync4(root, "utf8") : void 0;
5278
+ const result = source === void 0 ? void 0 : composeProviders(source, amendment, importPath);
5279
+ if (!result?.composed) {
5280
+ this.log.note(
5281
+ `The composition root no longer presents the shape this scaffold generated, so these were NOT added. ${amendment.without} Add them yourself: ${providerLines(amendment, importPath).join(" ")}`
5282
+ );
5283
+ return;
5284
+ }
5285
+ const kept = result.kept.map((line) => keptNote(line));
5286
+ if (result.source === source) {
5287
+ this.log.note(...kept.map((note) => `${note}; replace it to use the generated one.`));
5288
+ return;
5289
+ }
5290
+ this.log.plan(
5291
+ root,
5292
+ [
5293
+ ...amendment.providers.filter((provider) => !result.kept.includes(provider.line)).map((provider) => provider.line.replace(/,$/, "")),
5294
+ ...kept
4786
5295
  ],
4787
5296
  result.source
4788
5297
  );
@@ -4799,37 +5308,20 @@ var ProjectWiring = class {
4799
5308
  return this.project;
4800
5309
  }
4801
5310
  readConfig() {
4802
- this.config ??= asObject2(readJsonFile(this.workspace.configFile)) ?? {};
5311
+ this.config ??= asObject(readJsonFile(this.workspace.configFile)) ?? {};
4803
5312
  return this.config;
4804
5313
  }
4805
5314
  entryStylesheet(project) {
4806
- const entry = entryStylesheetOf(
4807
- buildTargetOf(this.readConfig(), project.name)?.value
4808
- );
5315
+ const target = buildTargetOf(this.readConfig(), project.name)?.value;
5316
+ const entry = entryStylesheet(asObject(asObject(target)?.["options"])?.["styles"]);
4809
5317
  return entry === void 0 ? void 0 : resolve6(this.workspace.root, entry);
4810
5318
  }
4811
5319
  };
4812
5320
  function composeNote(amendment, importPath) {
4813
5321
  return `The composition root no longer presents the shape this scaffold generated, so ${amendment.id} was NOT registered and none of its contributions will appear. Add these to it yourself: ` + composeLines(amendment, importPath).join(" ");
4814
5322
  }
4815
- function usesTailwind(css) {
4816
- return css.split("\n").some((line) => {
4817
- const directive = line.trimStart();
4818
- return /^@import\s+['"]tailwindcss['"]/.test(directive) || /^@source\s/.test(directive);
4819
- });
4820
- }
4821
- function relativeImport(fromDir, sourceRoot) {
4822
- const path = posix.relative(fromDir, sourceRoot);
4823
- return path.startsWith(".") ? path : `./${path}`;
4824
- }
4825
5323
 
4826
5324
  // src/lib/scaffold/amend.ts
4827
- var JS_POSTCSS_CONFIGS = [
4828
- "postcss.config.js",
4829
- "postcss.config.mjs",
4830
- "postcss.config.cjs",
4831
- ".postcssrc.js"
4832
- ];
4833
5325
  function planAmend(amendments2, target) {
4834
5326
  if (amendments2.length === 0) {
4835
5327
  return { amendments: [], remaining: [] };
@@ -4894,13 +5386,12 @@ var Amender = class {
4894
5386
  this.wiring.plan(amendment);
4895
5387
  }
4896
5388
  planPostcss(amendment) {
4897
- const inTheWay = JS_POSTCSS_CONFIGS.find(
4898
- (name) => existsSync7(resolve7(this.workspace.root, name))
5389
+ const writtenAsCode = postcssWrittenAsCode(
5390
+ (name) => existsSync7(resolve7(this.workspace.root, name)),
5391
+ amendment
4899
5392
  );
4900
- if (inTheWay) {
4901
- this.log.note(
4902
- `${inTheWay} is written as code and cannot be merged into, so add ${amendment.plugin} to it yourself; until then the stylesheet emits no utility class and the workbench renders unstyled.`
4903
- );
5393
+ if (writtenAsCode) {
5394
+ this.log.note(writtenAsCode);
4904
5395
  return;
4905
5396
  }
4906
5397
  const file = resolve7(this.workspace.root, amendment.file);
@@ -5027,14 +5518,10 @@ function reportAmendments(io, amend, done) {
5027
5518
  }
5028
5519
  }
5029
5520
  }
5521
+ var SCAFFOLD_FLAGS = ["out", "dry-run", "force"];
5030
5522
  function scaffold(args, io) {
5031
5523
  const descriptor = findScaffold2(args.command);
5032
- rejectUnknownFlags(args, [
5033
- ...allowedFlagsFor(descriptor),
5034
- "out",
5035
- "dry-run",
5036
- "force"
5037
- ]);
5524
+ rejectUnknownFlags(args, [...allowedFlagsFor(descriptor), ...SCAFFOLD_FLAGS]);
5038
5525
  const values = scaffoldValues(descriptor, args);
5039
5526
  const files = descriptor.build(values);
5040
5527
  const out = stringFlag(args, "out") ?? ".";
@@ -5093,8 +5580,14 @@ function reportFindings(io, findings, strict) {
5093
5580
  }
5094
5581
  return strict && gating.length > 0 ? 1 : 0;
5095
5582
  }
5583
+ var VALIDATE_FLAGS = {
5584
+ "validate-manifest": ["id", "name", "capabilities", "strict"],
5585
+ "validate-i18n": ["dir", "strict"],
5586
+ "validate-commands": ["dir", "strict"],
5587
+ "validate-catalog": ["file", "strict"]
5588
+ };
5096
5589
  function validateManifestCommand(args, io) {
5097
- rejectUnknownFlags(args, ["id", "name", "capabilities", "strict"]);
5590
+ rejectUnknownFlags(args, VALIDATE_FLAGS["validate-manifest"]);
5098
5591
  const capabilities = (stringFlag(args, "capabilities") ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
5099
5592
  return reportFindings(
5100
5593
  io,
@@ -5127,40 +5620,45 @@ function readBundles(dir) {
5127
5620
  return bundles;
5128
5621
  }
5129
5622
  function validateI18nCommand(args, io) {
5130
- rejectUnknownFlags(args, ["dir", "strict"]);
5623
+ rejectUnknownFlags(args, VALIDATE_FLAGS["validate-i18n"]);
5131
5624
  return reportFindings(
5132
5625
  io,
5133
5626
  validateI18nParity(readBundles(requiredFlag(args, "dir"))),
5134
5627
  boolFlag(args, "strict") === true
5135
5628
  );
5136
5629
  }
5630
+ var SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
5631
+ var NOT_SOURCE = /\.(?:d|spec|test)\.[cm]?[jt]sx?$/;
5632
+ function isCommandSource(name) {
5633
+ return SOURCE.test(name) && !NOT_SOURCE.test(name);
5634
+ }
5137
5635
  function readSources(dir) {
5138
5636
  const sources = [];
5139
- const walk = (folder) => {
5637
+ const walk2 = (folder) => {
5140
5638
  for (const entry of readdirSync2(folder, { withFileTypes: true })) {
5141
5639
  if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) {
5142
5640
  continue;
5143
5641
  }
5144
5642
  const path = join4(folder, entry.name);
5145
5643
  if (entry.isDirectory()) {
5146
- walk(path);
5147
- } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".spec.ts")) {
5644
+ walk2(path);
5645
+ } else if (isCommandSource(entry.name)) {
5148
5646
  sources.push({ path, text: readFileSync5(path, "utf8") });
5149
5647
  }
5150
5648
  }
5151
5649
  };
5152
5650
  try {
5153
- walk(dir);
5651
+ walk2(dir);
5154
5652
  } catch (error) {
5155
5653
  throw new ArgError(`Cannot read ${dir}: ${error.message}`);
5156
5654
  }
5157
5655
  if (sources.length === 0) {
5158
- throw new ArgError(`No TypeScript sources found under ${dir}.`);
5656
+ throw new ArgError(`No TypeScript or JavaScript sources found under ${dir}.`);
5159
5657
  }
5160
5658
  return sources;
5161
5659
  }
5162
5660
  function validateCommandsCommand(args, io) {
5163
- rejectUnknownFlags(args, ["dir", "strict"]);
5661
+ rejectUnknownFlags(args, VALIDATE_FLAGS["validate-commands"]);
5164
5662
  const dir = requiredFlag(args, "dir");
5165
5663
  const ts = loadTypeScript(dir);
5166
5664
  if (!ts) {
@@ -5190,7 +5688,7 @@ function readCatalog(file) {
5190
5688
  }
5191
5689
  }
5192
5690
  function validateCatalogCommand(args, io) {
5193
- rejectUnknownFlags(args, ["file", "strict"]);
5691
+ rejectUnknownFlags(args, VALIDATE_FLAGS["validate-catalog"]);
5194
5692
  return reportFindings(
5195
5693
  io,
5196
5694
  validateCatalog(readCatalog(requiredFlag(args, "file"))),
@@ -5199,7 +5697,7 @@ function validateCatalogCommand(args, io) {
5199
5697
  }
5200
5698
 
5201
5699
  // src/lib/run.ts
5202
- var VERSION = "0.15.2";
5700
+ var VERSION = "0.17.0";
5203
5701
  var COMMANDS = /* @__PURE__ */ new Map([
5204
5702
  ["init", init],
5205
5703
  ["list", list],