craftar 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Craft, sync and convert AI-coding workspace harnesses — rules, agents, commands, skills, MCP servers — across every client workspace you maintain and every AI coder each team uses.
4
4
 
5
- > Status: **phase 1 in progress** — phase 0, the foundations, is done. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) and `forge promote` (a profile's variant becomes the base) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. `craftar cache prune` removes the cache entries and trees nothing uses. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). Everything else in the spec (profiles with IDP/PM-tool integrations, `dotnet new` project templates, `craftar ui`, `craftar mcp`) is not built yet.
5
+ > Status: **phase 1 in progress** — phase 0, the foundations, is done. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) and `forge promote` (a profile's variant becomes the base) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. `craftar cache prune` removes the cache entries and trees nothing uses. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). `craftar templates list | install` shows and runs the profile's `templates` block — `install` runs `dotnet new install` per entry into the user's template store; nothing installs implicitly. Everything else in the spec (profiles with IDP/PM-tool integrations, `craftar ui`, `craftar mcp`) is not built yet.
6
6
 
7
7
  ## The idea in one paragraph
8
8
 
@@ -79,7 +79,9 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
79
79
  | `craftar explain <path>` | Which ingredient, recipe chain, target and origin produced a file and, for an ingredient with sections, which layer filled each one: `sections flavors (profile globex), extra (default)` (`default`, `profile <p>` or `workspace`). For an ingredient whose files cite parameters, each cited key with the layer that filled it for this ingredient: `params org (profile globex), port (default), root (unset)` — `default` (the ingredient's own), `recipe <name>`, `profile <p>`, `workspace` (either workspace file) or `unset`. `AGENTS.md` gets neither line: it is not one ingredient. |
80
80
  | `craftar ls` | Recipes and ingredients resolved for this workspace. |
81
81
  | `craftar workspaces [--fetch] [--json]` | Every workspace a writing `sync` registered on this machine, sorted by path: name, path, profile, stack (`slot=recipe`), targets, the Forge, the last sync, and a status computed now — `up to date`, `outdated` (`new`, `update` or `orphan` pending), `drift` (a `drift`, `orphan-drift` or `collision`), `no lock`, `missing` (directory or `craftar.yaml` gone) or `error` (it no longer loads, plans or reads its lock, or its `sync` would refuse for a declared parameter with no value — the reason is one of the row's warnings: `sync refused: declared parameter(s) with no value: <keys>`), plus `forge moved` when the Forge's commit differs from the lock's. Read without the network: a remote Forge comes from the cache; `--fetch` fetches first. Each row's warnings print after the table, prefixed with its path. Times are UTC. Writes nothing. Exits 0 whenever the table prints. Takes no `--workspace`. `--json` is a contract: `{registry, fetch, workspaces, warnings}`. |
82
- | `craftar doctor [--workspace <dir>] [--fetch] [--strict] [--json]` | Checks this machine — Node ≥ 22, `git`, `$CRAFTAR_HOME`, the registry (entries whose workspace is gone), the Forge cache (its size, entries nothing names, a fetch that never completed) — and, when run inside one (the current directory, or `--workspace`), this workspace: the configuration and its Forge (cached, fetched or pinned), whether the workspace sits inside its Forge, the plan's warnings, declared parameters with no value, the `${NAME}` variables a written MCP server expects and this environment lacks — `mcp-env` reads `authEnv` next to `${NAME}` (names only, never values) — the lock, `status`, and registration; in a workspace with a service: `docker` — Docker and its Compose plugin, 2.20.0 or later — and `services-env` — the variables a fragment needs. One line per finding — `ok`, `warn` or `error` — each problem with the command that fixes it; each cause is reported once. A declared parameter with no value is an `error` when a file the plan writes cites it (`sync` refuses that workspace) and a `warn` when nothing cites it. It only reports: nothing is written to a workspace, a lock or the registry, and the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; under `--fetch`, a fetch and the cleanup of trees unused for 14 days). Read without the network; `--fetch` fetches a remote Forge first. Exits 1 on an `error`, or on a `warn` under `--strict`; an explicit `--workspace` without `craftar.yaml` exits 1, while the current directory without one runs the machine checks only. `--json` is a contract: `{version, workspace, fetch, strict, checks: [{id, scope, level, message, fix}], summary}`. |
82
+ | `craftar doctor [--workspace <dir>] [--fetch] [--strict] [--json]` | Checks this machine — Node ≥ 22, `git`, `$CRAFTAR_HOME`, the registry (entries whose workspace is gone), the Forge cache (its size, entries nothing names, a fetch that never completed) — and, when run inside one (the current directory, or `--workspace`), this workspace: the configuration and its Forge (cached, fetched or pinned), whether the workspace sits inside its Forge, the plan's warnings, declared parameters with no value, the `${NAME}` variables a written MCP server expects and this environment lacks — `mcp-env` reads `authEnv` next to `${NAME}` (names only, never values), `templates` when the profile declares a `templates` block (an empty `templates:` line declares none), `dotnet` when that block is accepted and declares a `dotnet-new` entry (the .NET SDK answering `dotnet --version` at the workspace root); neither goes above `warn` — the lock, `status`, and registration; in a workspace with a service: `docker` — Docker and its Compose plugin, 2.20.0 or later — and `services-env` — the variables a fragment needs. One line per finding — `ok`, `warn` or `error` — each problem with the command that fixes it; each cause is reported once. A declared parameter with no value is an `error` when a file the plan writes cites it (`sync` refuses that workspace) and a `warn` when nothing cites it. It only reports: nothing is written to a workspace, a lock or the registry, and the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; under `--fetch`, a fetch and the cleanup of trees unused for 14 days). Read without the network; `--fetch` fetches a remote Forge first. Exits 1 on an `error`, or on a `warn` under `--strict`; an explicit `--workspace` without `craftar.yaml` exits 1, while the current directory without one runs the machine checks only. `--json` is a contract: `{version, workspace, fetch, strict, checks: [{id, scope, level, message, fix}], summary}`. |
83
+ | `craftar templates list [--json] [--workspace <dir>] [--offline]` | What the profile's `templates` block declares: one line per package — kind, package, version or `latest`, source or `default feeds` — and one per entry this craftar cannot install. Runs no program and does not know what is installed. `--json` is a contract: `{profile, templates: [{entry, kind, package, version, source, installable}], warnings}`. Exits 1 when the block is refused, naming the position and the rule, never a value. |
84
+ | `craftar templates install [--dry-run] [--force] [--interactive] [--workspace <dir>] [--offline]` | Runs `dotnet new install <pkg>[::<version>]... [--add-source <source>]` once per `dotnet-new` entry, in the profile's order, at the workspace root. `--dry-run` prints the exact lines, needs no `dotnet` and exits 0 over an accepted block, even with a skipped entry. A real run exits 1 when the .NET SDK is not available. Both exit 1 when the block is refused or a package ID exists as a file or folder at the workspace root. `--force` and `--interactive` are passed to `dotnet` as typed, never otherwise; `--interactive` also gives `dotnet` the terminal's input. An entry that fails does not stop the others; a real run exits 1 when any failed or was skipped. When an entry declares a `source` and the workspace root has no `NuGet.Config`, a warning line is printed before the runs. Writes nothing in the workspace, the lock or the registry; nothing else (`sync`, `init`, `doctor`) runs it. See *Project templates*. |
83
85
  | `craftar cache prune [--dry-run] [--workspace <dir>] [--json]` | Removes the Forge cache entries nothing uses — no registered workspace (`missing` ones included until `craftar workspaces prune`) and not the current workspace — entries whose first fetch never completed (even when named), and trees unused for 14 days. Whole entries are kept with a warning when the registry is off or unreadable, or the current `craftar.yaml` does not read. Deletes by default; `--dry-run` shows. Never touches a workspace, a lock or the registry; no network. One removing prune at a time per `$CRAFTAR_HOME` (`forges/prune.lock`). An explicit `--workspace` without `craftar.yaml` exits 1. `--json` is a contract: `{home, dryRun, removed: [{path, kind, reason, bytes}], kept: [{path, reason, namedBy}], freedBytes, warnings}`. |
84
86
  | `craftar workspaces forget <dir>` / `craftar workspaces prune` | `forget` removes one entry (the directory is not touched; an unregistered path exits 1); `prune` removes every `missing` entry and never an `error` one. Nothing else removes an entry. |
85
87
  | `craftar recipes [--forge <dir> [--profile <name>] \| --workspace <dir>] [--json]` | Every recipe of the Forge, sorted by name: slot, the parents it extends, the ingredients it lists itself and the profiles that reach it. In a workspace (or with `--forge <dir> --profile <name>`) each recipe says whether it is in use and why — `profile`, `workspace` (`recipes.add`), `extends <child>` — and a recipe `recipes.remove` names reads `removed by this workspace`. A context that does not resolve (two recipes on one slot, an unknown recipe, a cycle) still lists everything, marks nothing and warns. Read-only: it resolves, never plans. `--json` is a contract: `{forge, context, recipes, warnings}`. |
@@ -364,18 +366,48 @@ A writing `sync` records the workspace in `$CRAFTAR_HOME/registry.json` (`~/.cra
364
366
  - **`craftar init` reads it for the Forge it offers** when it asks: the Forge of the most recent `sync` on this machine, skipping a directory that is gone. The questions never write it — the first `sync` registers the new workspace as any `sync` does. With `CRAFTAR_NO_REGISTRY` set, or a registry it cannot read, no Forge is offered and the question is asked without a default.
365
367
  - **The registry also tells a Forge its workspaces**: `forge impact`, `forge unify` and `forge promote` read it (never write it); a workspace synced on CI, another machine or under `CRAFTAR_NO_REGISTRY` is not known to them, which is why `--prune-recipes` says so.
366
368
 
369
+ ## Project templates
370
+
371
+ `craftar templates list` shows what the profile declares; `craftar templates install` runs the installs. **Nothing installs implicitly** — not `sync`, not `init`, not `doctor`.
372
+
373
+ A profile can list templates to install:
374
+
375
+ ```yaml
376
+ # profiles/acme/profile.yaml
377
+ templates:
378
+ - kind: dotnet-new
379
+ source: https://pkgs.example.com/acme/nuget/v3/index.json
380
+ packages:
381
+ - Acme.Templates.Api
382
+ - Acme.Templates.Front::2.1.0
383
+ ```
384
+
385
+ A `kind` is a lowercase name (`dotnet-new`); a package is a NuGet ID, optionally with a version (`<id>::<version>`). A `source` is an `https://` URL with a host, in printable ASCII, and no user or password. There is no workspace layer: `craftar.yaml` and `craftar.local.yaml` cannot add or remove a template.
386
+
387
+ - **The store is the user's** — one per machine user, shared by every workspace; the last install of a package ID wins. Remove one with `dotnet new uninstall <package>`.
388
+ - **Feeds are additive** — a declared `source` is added to the feeds `dotnet` already uses, so an ID nobody reserved on a public feed can be served from there. Pin it with the team's own `NuGet.Config` at the workspace root (craftar looks there only, and generates none). `install` and `doctor` warn when an entry declares a `source` and the root has no `NuGet.Config`.
389
+ - **Private feeds** — authentication is NuGet's (`NuGet.Config`, a credential provider, `--interactive`); craftar holds no token, and a `source` carrying a user or a password is refused and never printed. A `source` is printed as written by `list`, `--dry-run` and `install`: craftar refuses a user or a password in it, not a token in its path or query — keep those out of the profile.
390
+ - **`dotnet` comes from `PATH`** — craftar looks for it itself, in the absolute entries of `PATH` only (on Windows, a `dotnet.exe` or `dotnet.com` in an entry with a drive or a UNC host), and never in the workspace root unless `PATH` names it: a `dotnet` committed in a repository is not run by `install` or by `doctor`'s probe, which both run there. An empty or relative entry, an unset `PATH` and a `.cmd` / `.bat` shim are not used.
391
+ - **Unknown kind** — an entry of a kind this craftar cannot install (`npm-create`, `nx`) is listed, skipped and warned; `install` then exits 1 (`--dry-run` still exits 0).
392
+
367
393
  ## Roadmap (from the functional spec)
368
394
 
369
395
  Phase 0 — foundations (done): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; `forge variants`, `forge diff` and `forge unify` (`take: param`, `take: section`); sections.
370
396
 
371
397
  Phase 1 — client profile (in progress). Shipped: the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock) and `craftar cache prune`; the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact`, `forge unify --prune-recipes` and `forge promote`; an interactive `craftar init` (choose the Forge and the profile); `authEnv` on MCP ingredients (the environment variables a server needs, by name, and the generated `.claude/settings.craftar.example.json`); `craftar drift show` / `drift discard` / `drift promote` (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; `craftar import --report` (a Markdown report of an import, as paths and line counts) and `import --prefer-kiro` (a rule's source taken from its Kiro steering file). Next: cloning a profile's repositories at init; profile-driven integrations (PM tool → MCP, IDP → MCP).
372
398
 
373
- Phase 2 — environment (in progress). Shipped: local services (`service` ingredients, `.craftar/`, `craftar services up | down | status`). Next: `dotnet new` template registration.
399
+ Phase 2 — environment (in progress). Shipped: local services (`service` ingredients, `.craftar/`, `craftar services up | down | status`); `craftar templates list | install` (`dotnet new` project templates declared in a profile).
374
400
 
375
401
  Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; `craftar mcp` + Claude Code / Agent Plugin packaging.
376
402
 
377
403
  ## Upgrading
378
404
 
405
+ ### to 0.26.0
406
+
407
+ - **A profile's `templates:` key is now kept at load and read by `craftar templates` and by `craftar doctor`.** Nothing else reads it: `sync`, `status` and every other command behave exactly as before, whatever the key holds.
408
+ - **`craftar doctor` gains two check ids, `templates` and `dotnet`**: `templates` when the profile declares a `templates` block (an empty `templates:` line declares none); `dotnet` when that block is accepted and declares a `dotnet-new` entry (the .NET SDK answering `dotnet --version` at the workspace root); neither goes above `warn`, so no exit code changes without `--strict`. **With `--strict`, four new causes exit 1**: a refused block (a mapping, as older examples showed, is one), an entry of an unknown kind, a `source` with no `NuGet.Config` at the workspace root, and the .NET SDK not being available.
409
+ - **No byte change**: no emitter and no lock changed. No workspace sees `update`.
410
+
379
411
  ### to 0.25.0
380
412
 
381
413
  - **A Forge that holds a `service` ingredient is read only by this version and later.** An older craftar loads every ingredient of a Forge and does not know the type: in **every** workspace of that Forge — also one whose recipes bring no service — `status`, `sync` and every other command that loads the Forge stop with a validation error naming `ingredients/services/<name>/ingredient.yaml`, and write nothing. `craftar workspaces`, which reads each workspace on its own, shows a workspace stopped this way as an `error` row. **Add the first service to a shared Forge only once every workspace of it — every member and the CI — runs this version.** With a remote Forge, a workspace pinned by `ref` to a commit from before the service does not read it.
package/dist/cli.js CHANGED
@@ -38,6 +38,8 @@ import { YamlSyntaxError, parseYamlText } from "./core/yaml-read.js";
38
38
  import { spawn } from "node:child_process";
39
39
  import { recordedServices, pendingLine, upRefusal, composeArgv, downArgv, readPs, dockerVersion, dockerFault, dockerRunner, projectName, statusReport } from "./core/services.js";
40
40
  import { errorText } from "./core/schema-fault.js";
41
+ import { declaredTemplates, DOTNET_MISSING, DOTNET_MISSING_FIX, dotnetVersion, feedMessage, installReport, installTemplates, profileLabel, templateRows, unpinnedSources } from "./core/templates.js";
42
+ import { capture, spawnInherited } from "./core/proc.js";
41
43
  process.stdout.on("error", (e) => { if (e.code === "EPIPE")
42
44
  process.exit(0); });
43
45
  /**
@@ -47,7 +49,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
47
49
  */
48
50
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
49
51
  const program = new Command();
50
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.25.0");
52
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.26.0");
51
53
  const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
52
54
  const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
53
55
  program
@@ -160,7 +162,7 @@ program
160
162
  */
161
163
  function refusalBlock(unset, opts = {}) {
162
164
  if (unset.length)
163
- console.error(pc.red("error: ") + unsetRefusal(unset, opts));
165
+ errorLine(unsetRefusal(unset, opts));
164
166
  return unset.length > 0;
165
167
  }
166
168
  /**
@@ -884,7 +886,7 @@ workspaces
884
886
  /* ---------------------------------------------------------------- doctor */
885
887
  program
886
888
  .command("doctor")
887
- .description("Check this machine (Node, git, $CRAFTAR_HOME, the registry, the Forge cache) and, inside one, this workspace (configuration and Forge, plan, declared parameters, MCP environment variables, lock, status, registration): one line per finding — ok, warn or error — each with its fix. Reports only; reads without the network unless --fetch")
889
+ .description("Check this machine (Node, git, $CRAFTAR_HOME, the registry, the Forge cache) and, inside one, this workspace (configuration and Forge, plan, declared parameters, MCP environment variables, the profile's templates block and dotnet when it has one, lock, status, registration): one line per finding — ok, warn or error — each with its fix. Reports only; reads without the network unless --fetch")
888
890
  // No commander default: an explicit --workspace without craftar.yaml exits 1, the current directory without one runs the machine checks (spec 24 §4.1).
889
891
  .option("--workspace <dir>", "the workspace to check (default: the current directory, when it holds craftar.yaml)")
890
892
  .option("--fetch", "fetch a remote Forge first instead of reading the cached copy", false)
@@ -915,6 +917,102 @@ program
915
917
  if (report.summary.error > 0 || (o.strict && report.summary.warn > 0))
916
918
  process.exitCode = 1;
917
919
  });
920
+ /* ---------------------------------------------------------------- templates */
921
+ const templates = program.command("templates").description("Project templates the profile declares (dotnet new): list them, or install them into this user's dotnet template store");
922
+ /** The profile's declared templates (spec 32 §5.2); a refused block is the command's error. */
923
+ async function declaredFor(ws) {
924
+ const profile = ws.forge.profiles.get(ws.config.profile);
925
+ if (!profile)
926
+ fail(`profile "${ws.config.profile}" not found in Forge`);
927
+ try {
928
+ return { profile: profile.name, declared: declaredTemplates(profile, await profileLabel(ws.forge.root, profile.name)) };
929
+ }
930
+ catch (e) {
931
+ fail(errorText(e));
932
+ }
933
+ }
934
+ templates
935
+ .command("list")
936
+ .description("What the profile's templates block declares: one line per package — kind, package, version or latest, source or default feeds — and one per entry this craftar cannot install. Runs no program and does not know what is installed")
937
+ .option("--json", "machine-readable output", false)
938
+ .option("--workspace <dir>", "workspace root", ".")
939
+ .option("--offline", "use the cached copy of a remote Forge, without fetching", false)
940
+ .action(async (o) => {
941
+ const ws = await loadWorkspace(o.workspace, load(o, "read"));
942
+ const { profile, declared } = await declaredFor(ws);
943
+ const rows = templateRows(declared.entries);
944
+ const warnings = [...ws.warnings, ...declared.warnings];
945
+ if (o.json) {
946
+ console.log(JSON.stringify({ profile, templates: rows, warnings }, null, 2));
947
+ return;
948
+ }
949
+ warnStderr(warnings);
950
+ if (!declared.declared || declared.entries.length === 0) {
951
+ console.log(`no templates declared for profile ${profile}`);
952
+ return;
953
+ }
954
+ // Text mode: header and one line per row
955
+ const installableRows = rows.filter((r) => r.installable);
956
+ const kindWidth = Math.max(...rows.map((r) => r.kind.length));
957
+ const packageWidth = installableRows.length ? Math.max(...installableRows.map((r) => (r.package ?? "").length)) : 0;
958
+ const versionWidth = installableRows.length ? Math.max(...installableRows.map((r) => (r.version ?? "latest").length)) : 0;
959
+ console.log(pc.bold(`Templates of profile ${profile}`));
960
+ for (const row of rows) {
961
+ if (row.installable) {
962
+ const pkg = (row.package ?? "").padEnd(packageWidth);
963
+ const ver = (row.version ?? "latest").padEnd(versionWidth);
964
+ const src = row.source ?? "default feeds";
965
+ console.log(` ${row.kind.padEnd(kindWidth)} ${pkg} ${ver} ${src}`);
966
+ }
967
+ else {
968
+ console.log(` ${row.kind.padEnd(kindWidth)} not installed by this craftar`);
969
+ }
970
+ }
971
+ });
972
+ templates
973
+ .command("install")
974
+ .description("Run dotnet new install once per dotnet-new entry, in the profile's order, at the workspace root; the packages land in this user's dotnet template store, shared by every workspace. Nothing else runs it. Exits 1 when an entry failed or was skipped (--dry-run exits 0 over an accepted block)")
975
+ .option("--dry-run", "print each dotnet new install line; run nothing (needs no dotnet)", false)
976
+ .option("--force", "pass --force to dotnet new install", false)
977
+ .option("--interactive", "pass --interactive to dotnet new install and give it the terminal's input", false)
978
+ .option("--workspace <dir>", "workspace root", ".")
979
+ .option("--offline", "use the cached copy of a remote Forge, without fetching", false)
980
+ .action(async (o) => {
981
+ const ws = await loadWorkspace(o.workspace, load(o, "read"));
982
+ const { profile, declared } = await declaredFor(ws);
983
+ const warnings = [...ws.warnings, ...declared.warnings];
984
+ warnStderr(warnings);
985
+ if (!declared.declared || declared.entries.length === 0) {
986
+ console.log(`no templates declared for profile ${profile}`);
987
+ return;
988
+ }
989
+ const hasInstallable = declared.entries.some((e) => e.installable);
990
+ if (hasInstallable && !o.dryRun) {
991
+ const version = await dotnetVersion(capture, process.env, ws.root);
992
+ if (version === null) {
993
+ fail(`${DOTNET_MISSING} — ${DOTNET_MISSING_FIX}, then run craftar templates install again (--dry-run shows the lines without it)`);
994
+ }
995
+ }
996
+ const unpinned = await unpinnedSources(declared.entries, ws.root);
997
+ if (unpinned.length > 0) {
998
+ warnStderr([`templates: ${feedMessage(unpinned)}`]);
999
+ }
1000
+ const announce = (entry, line) => console.log(`templates[${entry}]: ${line}`);
1001
+ let result;
1002
+ try {
1003
+ result = await installTemplates(declared.entries, ws.root, { dryRun: o.dryRun, force: o.force, interactive: o.interactive, env: process.env, announce }, spawnInherited);
1004
+ }
1005
+ catch (e) {
1006
+ fail(errorText(e));
1007
+ }
1008
+ const report = installReport(result, o.dryRun);
1009
+ for (const l of report.err)
1010
+ errorLine(l);
1011
+ for (const l of report.out)
1012
+ console.log(l);
1013
+ if (report.exitCode)
1014
+ process.exitCode = 1;
1015
+ });
918
1016
  /* ---------------------------------------------------------------- recipes */
919
1017
  program
920
1018
  .command("recipes")
@@ -2448,8 +2546,12 @@ function forgeJson(ws, lock) {
2448
2546
  fetched: o.fetched,
2449
2547
  };
2450
2548
  }
2451
- function fail(msg) {
2549
+ /** One line on stderr with the `error: ` prefix, without exiting — `fail()` is this, then exit 1. */
2550
+ function errorLine(msg) {
2452
2551
  console.error(pc.red("error: ") + msg);
2552
+ }
2553
+ function fail(msg) {
2554
+ errorLine(msg);
2453
2555
  process.exit(1);
2454
2556
  }
2455
2557
  function printStatus(st, warnings, profile, recipes, compact = false, forge = null) {