@icebreakers/monorepo-templates 2.0.7 → 2.2.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 (167) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/assets/.agents/skills/repoctl/SKILL.md +4 -2
  4. package/assets/.agents/skills/repoctl/references/commands.md +224 -13
  5. package/assets/.agents/skills/repoctl/references/config.md +70 -6
  6. package/assets/.agents/skills/repoctl/references/dependencies.md +37 -0
  7. package/assets/.agents/skills/repoctl/references/knip.md +34 -0
  8. package/assets/.agents/skills/repoctl/references/templates.md +26 -2
  9. package/assets/.github/workflows/ci.yml +10 -14
  10. package/assets/package.json +8 -16
  11. package/assets/pnpm-workspace.yaml +4 -1
  12. package/assets/repoctl.config.ts +1 -1
  13. package/assets/turbo.json +12 -4
  14. package/dist/cli.mjs +1 -1
  15. package/dist/index.d.mts +281 -1
  16. package/dist/index.mjs +1138 -75
  17. package/dist/{prepare-BapI8ioj.mjs → prepare-BYNSxvoB.mjs} +96 -4
  18. package/package.json +2 -1
  19. package/template-data.mjs +32 -0
  20. package/templates/client/package.json +2 -2
  21. package/templates/next/README.md +31 -0
  22. package/templates/next/eslint.config.js +5 -0
  23. package/templates/next/gitignore +7 -0
  24. package/templates/next/next.config.ts +5 -0
  25. package/templates/next/package.json +36 -0
  26. package/templates/next/src/app/api/health/route.ts +5 -0
  27. package/templates/next/src/app/counter.tsx +19 -0
  28. package/templates/next/src/app/favicon.ico +0 -0
  29. package/templates/next/src/app/globals.css +109 -0
  30. package/templates/next/src/app/layout.tsx +17 -0
  31. package/templates/next/src/app/page.tsx +16 -0
  32. package/templates/next/stylelint.config.js +3 -0
  33. package/templates/next/test/counter.test.tsx +22 -0
  34. package/templates/next/tsconfig.json +40 -0
  35. package/templates/next/turbo.json +24 -0
  36. package/templates/next/vitest.config.js +9 -0
  37. package/templates/nimbus/AGENTS.md +12 -0
  38. package/templates/nimbus/LICENSE +21 -0
  39. package/templates/nimbus/README.md +17 -0
  40. package/templates/nimbus/UPSTREAM.md +16 -0
  41. package/templates/nimbus/astro.config.ts +28 -0
  42. package/templates/nimbus/eslint.config.js +25 -0
  43. package/templates/nimbus/gitignore +5 -0
  44. package/templates/nimbus/nimbus.json +4 -0
  45. package/templates/nimbus/package.json +34 -0
  46. package/templates/nimbus/public/favicon.svg +1 -0
  47. package/templates/nimbus/scripts/check-locales.mjs +11 -0
  48. package/templates/nimbus/src/components/aside.astro +9 -0
  49. package/templates/nimbus/src/components/head.astro +43 -0
  50. package/templates/nimbus/src/components/search/client.ts +62 -0
  51. package/templates/nimbus/src/components/search/index.astro +26 -0
  52. package/templates/nimbus/src/components/sidebar.astro +26 -0
  53. package/templates/nimbus/src/components.ts +3 -0
  54. package/templates/nimbus/src/content/docs/get-started.mdx +36 -0
  55. package/templates/nimbus/src/content/docs/index.mdx +16 -0
  56. package/templates/nimbus/src/content/docs/writing.mdx +35 -0
  57. package/templates/nimbus/src/content/docs/zh/get-started.mdx +36 -0
  58. package/templates/nimbus/src/content/docs/zh/index.mdx +16 -0
  59. package/templates/nimbus/src/content/docs/zh/writing.mdx +35 -0
  60. package/templates/nimbus/src/content.config.ts +6 -0
  61. package/templates/nimbus/src/env.d.ts +6 -0
  62. package/templates/nimbus/src/layouts/docs.astro +70 -0
  63. package/templates/nimbus/src/lib/locale.ts +50 -0
  64. package/templates/nimbus/src/pages/[...slug]/index.md.ts +4 -0
  65. package/templates/nimbus/src/pages/[...slug]/index.mdx.ts +4 -0
  66. package/templates/nimbus/src/pages/[...slug].astro +36 -0
  67. package/templates/nimbus/src/pages/[section]/llms.txt.ts +4 -0
  68. package/templates/nimbus/src/pages/llms-full.txt.ts +4 -0
  69. package/templates/nimbus/src/pages/llms.txt.ts +4 -0
  70. package/templates/nimbus/src/pages/robots.txt.ts +18 -0
  71. package/templates/nimbus/src/styles/base.css +121 -0
  72. package/templates/nimbus/src/styles/site.css +186 -0
  73. package/templates/nimbus/stylelint.config.js +3 -0
  74. package/templates/nimbus/tsconfig.json +10 -0
  75. package/templates/react-lib/LICENSE +21 -0
  76. package/templates/react-lib/README.md +52 -0
  77. package/templates/react-lib/eslint.config.js +5 -0
  78. package/templates/react-lib/gitignore +4 -0
  79. package/templates/react-lib/package.json +48 -0
  80. package/templates/react-lib/src/counter.tsx +28 -0
  81. package/templates/react-lib/src/index.ts +2 -0
  82. package/templates/react-lib/src/style.css +36 -0
  83. package/templates/react-lib/stylelint.config.js +3 -0
  84. package/templates/react-lib/test/counter.test.tsx +28 -0
  85. package/templates/react-lib/test-d/index.test-d.ts +11 -0
  86. package/templates/react-lib/tsconfig.app.json +15 -0
  87. package/templates/react-lib/tsconfig.build.json +12 -0
  88. package/templates/react-lib/tsconfig.json +4 -0
  89. package/templates/react-lib/tsconfig.node.json +11 -0
  90. package/templates/react-lib/vite.config.ts +22 -0
  91. package/templates/react-lib/vitest.config.ts +5 -0
  92. package/templates/react-vite/README.md +27 -0
  93. package/templates/react-vite/eslint.config.js +5 -0
  94. package/templates/react-vite/gitignore +6 -0
  95. package/templates/react-vite/index.html +13 -0
  96. package/templates/react-vite/package.json +36 -0
  97. package/templates/react-vite/src/app.tsx +22 -0
  98. package/templates/react-vite/src/main.tsx +15 -0
  99. package/templates/react-vite/src/style.css +95 -0
  100. package/templates/react-vite/stylelint.config.js +3 -0
  101. package/templates/react-vite/test/app.test.tsx +22 -0
  102. package/templates/react-vite/tsconfig.app.json +26 -0
  103. package/templates/react-vite/tsconfig.json +11 -0
  104. package/templates/react-vite/tsconfig.node.json +18 -0
  105. package/templates/react-vite/vite.config.ts +6 -0
  106. package/templates/react-vite/vitest.config.ts +9 -0
  107. package/templates/server/package.json +1 -1
  108. package/templates/vitepress/.vitepress/config.ts +4 -5
  109. package/templates/vitepress/.vitepress/home/content.ts +2 -2
  110. package/templates/vitepress/.vitepress/home/faq.ts +16 -0
  111. package/templates/vitepress/.vitepress/mermaid/markdown.ts +18 -0
  112. package/templates/vitepress/.vitepress/mermaid/mermaid.vue +66 -0
  113. package/templates/vitepress/.vitepress/mermaid/render.ts +37 -0
  114. package/templates/vitepress/.vitepress/navigation/routes.ts +7 -0
  115. package/templates/vitepress/.vitepress/seo.ts +12 -1
  116. package/templates/vitepress/.vitepress/theme/components/HomePage.vue +2 -16
  117. package/templates/vitepress/.vitepress/theme/index.ts +3 -1
  118. package/templates/vitepress/ai/create.md +1 -1
  119. package/templates/vitepress/package.json +4 -8
  120. package/templates/vitepress/reference/artifacts.md +51 -0
  121. package/templates/vitepress/reference/commands.md +152 -3
  122. package/templates/vitepress/reference/config.md +101 -7
  123. package/templates/vitepress/reference/dependencies.md +116 -0
  124. package/templates/vitepress/reference/dependency-admission.md +67 -0
  125. package/templates/vitepress/reference/devcontainer.md +25 -0
  126. package/templates/vitepress/reference/index.md +1 -0
  127. package/templates/vitepress/reference/install-security.md +51 -0
  128. package/templates/vitepress/reference/knip.md +55 -0
  129. package/templates/vitepress/reference/move.md +39 -0
  130. package/templates/vitepress/reference/organization-presets.md +89 -0
  131. package/templates/vitepress/reference/removal.md +42 -0
  132. package/templates/vitepress/reference/template-assets.md +40 -1
  133. package/templates/vitepress/reference/templates.md +376 -9
  134. package/templates/vitepress/start/diagnose.md +19 -2
  135. package/templates/vitepress/tasks/checks.md +149 -1
  136. package/templates/vitepress/tasks/ci.md +93 -1
  137. package/templates/vitepress/tasks/index.md +1 -1
  138. package/templates/vitepress/tasks/release.md +72 -6
  139. package/templates/vitepress/test/mermaid.test.ts +39 -0
  140. package/templates/vitepress/test/render.test.ts +48 -0
  141. package/templates/vitepress/test/seo.test.ts +19 -0
  142. package/templates/vitepress/tsconfig.json +8 -8
  143. package/templates/vitepress/vitest.config.ts +8 -0
  144. package/templates/vitepress/zh/ai/create.md +1 -1
  145. package/templates/vitepress/zh/learn/monorepo/manage.md +1 -1
  146. package/templates/vitepress/zh/reference/artifacts.md +51 -0
  147. package/templates/vitepress/zh/reference/commands.md +151 -3
  148. package/templates/vitepress/zh/reference/config.md +94 -2
  149. package/templates/vitepress/zh/reference/dependencies.md +116 -0
  150. package/templates/vitepress/zh/reference/dependency-admission.md +67 -0
  151. package/templates/vitepress/zh/reference/devcontainer.md +25 -0
  152. package/templates/vitepress/zh/reference/execution-model.md +1 -1
  153. package/templates/vitepress/zh/reference/index.md +1 -0
  154. package/templates/vitepress/zh/reference/install-security.md +51 -0
  155. package/templates/vitepress/zh/reference/knip.md +55 -0
  156. package/templates/vitepress/zh/reference/move.md +39 -0
  157. package/templates/vitepress/zh/reference/organization-presets.md +89 -0
  158. package/templates/vitepress/zh/reference/removal.md +42 -0
  159. package/templates/vitepress/zh/reference/template-assets.md +22 -6
  160. package/templates/vitepress/zh/reference/templates.md +356 -9
  161. package/templates/vitepress/zh/start/diagnose.md +24 -13
  162. package/templates/vitepress/zh/tasks/checks.md +150 -0
  163. package/templates/vitepress/zh/tasks/ci.md +93 -1
  164. package/templates/vitepress/zh/tasks/index.md +1 -1
  165. package/templates/vitepress/zh/tasks/release.md +72 -6
  166. package/templates/vitepress/zh/tasks/reports.md +1 -1
  167. package/templates/vue-lib/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,9 +1,14 @@
1
- import { a as isGitignoreFile, c as toWorkspaceGitignorePath, d as packageDir, f as skeletonDir, h as getAssetTargets$1, i as shouldSkipTemplatePath, l as templateChoices$1, m as assetTargets$1, n as sanitizePublishedWorkspaceContent, o as toPublishGitignorePath, p as templatesDir, r as createTemplateCopyFilter, s as toWorkspaceAssetPath, t as prepareAssets, u as assetsDir } from "./prepare-BapI8ioj.mjs";
1
+ import { a as isGitignoreFile, c as toWorkspaceGitignorePath, d as packageDir, f as skeletonDir, h as getAssetTargets$1, i as shouldSkipTemplatePath, l as templateChoices$1, m as assetTargets$1, n as sanitizePublishedWorkspaceContent, o as toPublishGitignorePath, p as templatesDir, r as createTemplateCopyFilter, s as toWorkspaceAssetPath, t as prepareAssets, u as assetsDir } from "./prepare-BYNSxvoB.mjs";
2
2
  import path from "node:path";
3
3
  import fs, { access, open, readFile, rm } from "node:fs/promises";
4
+ import { Buffer } from "node:buffer";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import process from "node:process";
4
7
  import exec from "nanoexec";
8
+ import { isDeepStrictEqual } from "node:util";
5
9
  import checkbox from "@inquirer/checkbox";
6
10
  import input from "@inquirer/input";
11
+ import password from "@inquirer/password";
7
12
  import select from "@inquirer/select";
8
13
  import { Command, program } from "commander";
9
14
  //#region src/package-manager/read.ts
@@ -38,11 +43,12 @@ async function acquireLock(lockPath) {
38
43
  throw error;
39
44
  }
40
45
  }
41
- async function isPrepared() {
46
+ /** Inspect asset readiness without acquiring locks or writing generated files. */
47
+ async function areTemplateAssetsPrepared() {
42
48
  const checks = [
43
49
  path.join(assetsDir, "AGENTS.md"),
44
50
  path.join(assetsDir, "LICENSE"),
45
- path.join(templatesDir, "tsdown")
51
+ ...templateChoices$1.map((template) => path.join(templatesDir, template.source))
46
52
  ];
47
53
  if (!(await Promise.all(checks.map(pathExists))).every(Boolean)) return false;
48
54
  const sourceRoot = path.resolve(packageDir, "..", "..");
@@ -97,13 +103,13 @@ async function isPrepared() {
97
103
  async function waitForPrepared() {
98
104
  const deadline = Date.now() + lockTimeoutMs;
99
105
  while (Date.now() < deadline) {
100
- if (await isPrepared()) return true;
106
+ if (await areTemplateAssetsPrepared()) return true;
101
107
  await new Promise((resolve) => setTimeout(resolve, lockPollIntervalMs));
102
108
  }
103
109
  return false;
104
110
  }
105
111
  async function runEnsure() {
106
- if (await isPrepared()) return;
112
+ if (await areTemplateAssetsPrepared()) return;
107
113
  const lockPath = path.join(packageDir, lockFileName);
108
114
  let lockHandle = await acquireLock(lockPath);
109
115
  if (!lockHandle) {
@@ -134,91 +140,373 @@ async function getWorkspacePackageManager() {
134
140
  return readPackageManagerFromManifest(path.join(assetsDir, "package.json"));
135
141
  }
136
142
  //#endregion
137
- //#region src/scaffold.ts
138
- async function isEmptyDir(dir) {
143
+ //#region src/instances/paths.ts
144
+ const templateRegistryPath = ".repoctl/template-instances.json";
145
+ const templateBaselineDirectory = ".repoctl/template-baselines";
146
+ function portableRelativePath(value) {
147
+ if (!value || value.includes("\\") || value.includes("\0") || path.isAbsolute(value) || /^[a-z]:/iu.test(value) || value.split("/").some((part) => !part || part === "." || part === "..")) throw new Error(`Unsafe template instance path: ${value}`);
148
+ return value;
149
+ }
150
+ /** Reject links in every existing ancestor, including a missing leaf's parents. */
151
+ async function safeInstancePath(workspaceDir, relativePath) {
152
+ const relative = portableRelativePath(relativePath);
153
+ let current = await fs.realpath(workspaceDir);
154
+ for (const part of relative.split("/")) {
155
+ current = path.join(current, part);
156
+ try {
157
+ if ((await fs.lstat(current)).isSymbolicLink()) throw new Error(`Symlinks are not supported in template instance paths: ${relative}`);
158
+ } catch (error) {
159
+ if (error.code !== "ENOENT") throw error;
160
+ }
161
+ }
162
+ return current;
163
+ }
164
+ function instanceRelativePath(workspaceDir, targetDir) {
165
+ const relative = path.relative(path.resolve(workspaceDir), path.resolve(targetDir)).split(path.sep).join("/");
166
+ portableRelativePath(relative);
167
+ if (relative === ".repoctl" || relative.startsWith(".repoctl/")) throw new Error("Template instances cannot own the workspace metadata directory.");
168
+ return relative;
169
+ }
170
+ async function exists(file) {
139
171
  try {
140
- return (await fs.readdir(dir)).length === 0;
172
+ await fs.lstat(file);
173
+ return true;
141
174
  } catch (error) {
142
- if (error && error.code === "ENOENT") return true;
175
+ if (error.code === "ENOENT") return false;
143
176
  throw error;
144
177
  }
145
178
  }
146
- async function prepareTargetDir(dir, options = {}) {
147
- if (await isEmptyDir(dir)) {
148
- await fs.mkdir(dir, { recursive: true });
149
- return;
179
+ //#endregion
180
+ //#region src/parameters/schema.ts
181
+ const parameterName = /^[a-z]\w*$/u;
182
+ const isParameterRecord = (value) => value !== null && typeof value === "object" && [Object.prototype, null].includes(Object.getPrototypeOf(value));
183
+ function parameterError(path, reason) {
184
+ throw new Error(`Template parameter ${path}: ${reason}`);
185
+ }
186
+ function parameterValueMatches(definition, value) {
187
+ return definition.type === "boolean" ? typeof value === "boolean" : typeof value === "string" && value.length <= 65536 && (definition.type !== "enum" || definition.options.includes(value));
188
+ }
189
+ function validateTemplateParameterSchema(value) {
190
+ if (!isParameterRecord(value) || Object.keys(value).length > 64) parameterError("parameters", "expected an object containing at most 64 definitions");
191
+ for (const [name, item] of Object.entries(value)) {
192
+ const field = `parameters.${name}`;
193
+ if (!parameterName.test(name) || [
194
+ "constructor",
195
+ "prototype",
196
+ "__proto__"
197
+ ].includes(name)) parameterError(field, "use a portable parameter name");
198
+ if (!isParameterRecord(item) || ![
199
+ "string",
200
+ "boolean",
201
+ "enum"
202
+ ].includes(String(item["type"]))) parameterError(field, "expected a string, boolean or enum definition");
203
+ const allowed = [
204
+ "type",
205
+ "description",
206
+ "required",
207
+ "sensitive",
208
+ "default",
209
+ ...item["type"] === "enum" ? ["options"] : []
210
+ ];
211
+ if (Object.keys(item).some((key) => !allowed.includes(key))) parameterError(field, "unsupported definition field");
212
+ if (item["description"] !== void 0 && typeof item["description"] !== "string" || item["required"] !== void 0 && typeof item["required"] !== "boolean" || item["sensitive"] !== void 0 && typeof item["sensitive"] !== "boolean") parameterError(field, "invalid description, required or sensitive declaration");
213
+ if (item["sensitive"] === true && (item["type"] !== "string" || Object.hasOwn(item, "default"))) parameterError(field, "sensitive inputs must be strings without stored defaults");
214
+ if (item["type"] === "enum" && (!Array.isArray(item["options"]) || !item["options"].length || item["options"].length > 100 || item["options"].some((option) => typeof option !== "string" || !option.length || option.length > 1024) || new Set(item["options"]).size !== item["options"].length)) parameterError(field, "enum options must be unique nonempty strings");
215
+ if (Object.hasOwn(item, "default") && !parameterValueMatches(item, item["default"])) parameterError(`${field}.default`, "default does not satisfy its declared type");
150
216
  }
151
- if (!options.force) throw new Error(`Target directory ${dir} is not empty. Pass --force to overwrite.`);
152
- await fs.rm(dir, {
153
- recursive: true,
154
- force: true
155
- });
156
- await fs.mkdir(dir, { recursive: true });
157
217
  }
158
- async function copyDirContents(sourceDir, targetDir, options = {}) {
159
- const rootDir = options.rootDir ?? sourceDir;
160
- const filter = options.filter ?? ((src) => !shouldSkipTemplatePath(rootDir, src));
161
- const renameEntry = options.renameEntry ?? toWorkspaceGitignorePath;
162
- const skipRoot = new Set(options.skipRootBasenames ?? []);
163
- await fs.mkdir(targetDir, { recursive: true });
164
- const entries = await fs.readdir(sourceDir, { withFileTypes: true });
165
- for (const entry of entries) {
166
- if (sourceDir === rootDir && skipRoot.has(entry.name)) continue;
167
- const from = path.join(sourceDir, entry.name);
168
- if (!filter(from)) continue;
169
- const targetName = renameEntry(entry.name);
170
- const to = path.join(targetDir, targetName);
171
- if (entry.isDirectory()) {
172
- const nestedOptions = {
173
- rootDir,
174
- filter,
175
- renameEntry
176
- };
177
- if (options.skipRootBasenames) nestedOptions.skipRootBasenames = options.skipRootBasenames;
178
- await copyDirContents(from, to, { ...nestedOptions });
179
- continue;
218
+ //#endregion
219
+ //#region src/instances/remote.ts
220
+ /** Registry identities contain reproducible public coordinates, never authentication settings. */
221
+ function validateRemoteInstanceSource(source, exactVersion) {
222
+ const remote = source.remote;
223
+ if (source.kind !== "remote") {
224
+ if (remote !== void 0) throw new Error("Only remote template sources can contain remote coordinates.");
225
+ return;
226
+ }
227
+ if (!remote || !/^[a-f0-9]{64}$/u.test(source.digest ?? "") || typeof remote.integrity !== "string" || !/^(?:sha512|sha384|sha256|sha1)-[A-Za-z0-9+/]+={0,2}(?:\s+(?:sha512|sha384|sha256|sha1)-[A-Za-z0-9+/]+={0,2})*$/u.test(remote.integrity)) throw new Error("Remote template identity needs content digest and archive integrity.");
228
+ const fields = remote.kind === "npm" ? [
229
+ "kind",
230
+ "packageName",
231
+ "requestedVersion",
232
+ "version",
233
+ "registry",
234
+ "integrity"
235
+ ] : [
236
+ "kind",
237
+ "repository",
238
+ "requestedRef",
239
+ "commit",
240
+ "integrity"
241
+ ];
242
+ if (Object.keys(remote).some((field) => !fields.includes(field))) throw new Error("Remote template identity contains unsupported metadata.");
243
+ try {
244
+ if (remote.kind === "npm") {
245
+ const registry = new URL(remote.registry);
246
+ if (!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/u.test(remote.packageName) || !exactVersion.test(remote.version) || remote.requestedVersion !== remote.version || !["https:", "http:"].includes(registry.protocol) || registry.username || registry.password || registry.search || registry.hash) throw new Error("Invalid npm identity.");
247
+ return;
180
248
  }
181
- if (entry.isSymbolicLink()) {
182
- const link = await fs.readlink(from);
183
- await fs.symlink(link, to);
184
- continue;
249
+ if (remote.kind === "git") {
250
+ const repository = new URL(remote.repository);
251
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(remote.commit) || !remote.requestedRef || /\s/u.test(remote.requestedRef) || ![
252
+ "https:",
253
+ "ssh:",
254
+ "file:"
255
+ ].includes(repository.protocol) || repository.password || repository.username && !(repository.protocol === "ssh:" && repository.username === "git") || repository.search || repository.hash) throw new Error("Invalid Git identity.");
256
+ return;
185
257
  }
186
- await fs.cp(from, to);
258
+ } catch {
259
+ throw new Error("Remote template coordinates must identify an exact version or commit without credentials.");
187
260
  }
261
+ throw new Error("Unknown remote template identity kind.");
188
262
  }
189
- function isDefaultTemplatesPath(sourceDir) {
190
- const resolvedSource = path.resolve(sourceDir);
191
- const resolvedTemplatesRoot = path.resolve(templatesDir);
192
- return resolvedSource === resolvedTemplatesRoot || resolvedSource.startsWith(`${resolvedTemplatesRoot}${path.sep}`);
263
+ //#endregion
264
+ //#region src/instances/schema.ts
265
+ const digestPattern = /^[a-f0-9]{64}$/u;
266
+ const numericIdentifier = "(?:0|[1-9]\\d*)";
267
+ const prereleaseIdentifier = "(?:0|[1-9]\\d*|\\d*[a-z-][\\da-z-]*)";
268
+ const exactVersionPattern = new RegExp(`^${numericIdentifier}\\.${numericIdentifier}\\.${numericIdentifier}(?:-${prereleaseIdentifier}(?:\\.${prereleaseIdentifier})*)?(?:\\+[\\da-z-]+(?:\\.[\\da-z-]+)*)?$`, "iu");
269
+ function isExactTemplateVersion(value) {
270
+ return exactVersionPattern.test(value);
193
271
  }
194
- async function scaffoldTemplate(options) {
195
- const { sourceDir, targetDir, ensureTargetDir = true, filter, renameEntry, skipRootBasenames } = options;
196
- if (isDefaultTemplatesPath(sourceDir)) await ensureTemplateAssetsPrepared();
197
- if (ensureTargetDir) await fs.mkdir(targetDir, { recursive: true });
198
- const copyOptions = { rootDir: sourceDir };
199
- if (filter) copyOptions.filter = filter;
200
- if (renameEntry) copyOptions.renameEntry = renameEntry;
201
- if (skipRootBasenames) copyOptions.skipRootBasenames = skipRootBasenames;
202
- await copyDirContents(sourceDir, targetDir, copyOptions);
272
+ function normalizeTemplateExclusions(values) {
273
+ if (!Array.isArray(values) || values.some((value) => typeof value !== "string")) throw new Error("Template exclusions must be relative file or directory paths.");
274
+ const result = [];
275
+ for (const input of values.map((value) => value.endsWith("/**") ? value.slice(0, -3) : value).sort()) {
276
+ portableRelativePath(input);
277
+ if (/[*?[\]{}]/u.test(input)) throw new Error("Template exclusions support exact files or directories, not arbitrary glob patterns.");
278
+ if (!result.some((parent) => input === parent || input.startsWith(`${parent}/`))) result.push(input);
279
+ }
280
+ return result;
203
281
  }
204
- async function scaffoldWorkspace(options) {
205
- const { targetDir, templateKeys = [], assetsDir: assetsDir$1 = assetsDir, templatesDir: templatesDir$1 = templatesDir, includeAssets = true, targetMode = "prepare", force = false } = options;
206
- if (includeAssets && path.resolve(assetsDir$1) === path.resolve(assetsDir) || templateKeys.length > 0 && path.resolve(templatesDir$1) === path.resolve(templatesDir)) await ensureTemplateAssetsPrepared();
207
- if (targetMode === "prepare") await prepareTargetDir(targetDir, { force });
208
- else if (targetMode === "ensure") await fs.mkdir(targetDir, { recursive: true });
209
- if (includeAssets) await copyDirContents(assetsDir$1, targetDir, {
210
- rootDir: assetsDir$1,
211
- renameEntry: toWorkspaceAssetPath
282
+ function onlyFields(value, fields) {
283
+ if (!value || typeof value !== "object" || Object.keys(value).some((key) => !fields.includes(key))) throw new Error("Unsupported fields in template instance metadata; arbitrary user data is not stored.");
284
+ }
285
+ function generationParameters(input = {}) {
286
+ if (Object.keys(input).some((key) => ![
287
+ "packageName",
288
+ "renameJson",
289
+ "templateValues",
290
+ "sensitiveParameters"
291
+ ].includes(key)) || input.packageName !== void 0 && (typeof input.packageName !== "string" || !/^[@\w./-]+$/u.test(input.packageName)) || input.renameJson !== void 0 && typeof input.renameJson !== "boolean") throw new Error("Unsupported or invalid non-secret generation parameters.");
292
+ if (input.templateValues !== void 0 && (!isParameterRecord(input.templateValues) || Object.entries(input.templateValues).some(([key, value]) => !parameterName.test(key) || [
293
+ "constructor",
294
+ "prototype",
295
+ "__proto__"
296
+ ].includes(key) || !["string", "boolean"].includes(typeof value)))) throw new Error("Template generation values must be validated non-secret strings or booleans.");
297
+ if (input.sensitiveParameters !== void 0 && (!Array.isArray(input.sensitiveParameters) || input.sensitiveParameters.some((key) => typeof key !== "string" || !parameterName.test(key) || Object.hasOwn(input.templateValues ?? {}, key)) || new Set(input.sensitiveParameters).size !== input.sensitiveParameters.length)) throw new Error("Sensitive generation parameters retain unique names only, never values.");
298
+ return {
299
+ ...input.templateValues !== void 0 ? { templateValues: { ...input.templateValues } } : {},
300
+ ...input.sensitiveParameters !== void 0 ? { sensitiveParameters: [...input.sensitiveParameters].sort() } : {},
301
+ ...input.packageName !== void 0 ? { packageName: input.packageName } : {},
302
+ ...input.renameJson !== void 0 ? { renameJson: input.renameJson } : {}
303
+ };
304
+ }
305
+ function validateInstance(instance) {
306
+ onlyFields(instance, [
307
+ "id",
308
+ "target",
309
+ "template",
310
+ "provenance",
311
+ "source",
312
+ "generator",
313
+ "parameters",
314
+ "baseline",
315
+ "excludedPaths"
316
+ ]);
317
+ onlyFields(instance.generator, ["profile", "version"]);
318
+ onlyFields(instance.parameters, [
319
+ "packageName",
320
+ "renameJson",
321
+ "templateValues",
322
+ "sensitiveParameters"
323
+ ]);
324
+ onlyFields(instance.source, [
325
+ "kind",
326
+ "templatePath",
327
+ "packageName",
328
+ "version",
329
+ "digest",
330
+ "remote"
331
+ ]);
332
+ onlyFields(instance.baseline, [
333
+ "status",
334
+ "original",
335
+ "rendered",
336
+ "reason"
337
+ ]);
338
+ if (!instance || !/^[a-f0-9]{24}$/u.test(instance.id) || typeof instance.template !== "string" || !instance.template || !["created", "linked"].includes(instance.provenance) || ![
339
+ "workspace-copy-v1",
340
+ "repo-new-v1",
341
+ "repo-new-parameters-v1"
342
+ ].includes(instance.generator?.profile) || !exactVersionPattern.test(instance.generator.version)) throw new Error("Invalid template instance identity or generator.");
343
+ portableRelativePath(instance.target);
344
+ if (instance.target === ".repoctl" || instance.target.startsWith(".repoctl/")) throw new Error("Template instances cannot own metadata paths.");
345
+ generationParameters(instance.parameters);
346
+ if (instance.excludedPaths !== void 0 && JSON.stringify(normalizeTemplateExclusions(instance.excludedPaths)) !== JSON.stringify(instance.excludedPaths)) throw new Error("Template exclusions must use sorted, canonical non-overlapping paths.");
347
+ const source = instance.source;
348
+ if (source.kind !== "remote" || source.templatePath !== ".") portableRelativePath(source.templatePath);
349
+ validateRemoteInstanceSource(source, exactVersionPattern);
350
+ if (![
351
+ "package",
352
+ "snapshot",
353
+ "remote"
354
+ ].includes(source.kind) || source.kind === "package" && (source.packageName !== "@icebreakers/monorepo-templates" || !exactVersionPattern.test(source.version ?? "")) || source.digest !== void 0 && !digestPattern.test(source.digest)) throw new Error("Template source must use an exact package version or immutable snapshot.");
355
+ if (instance.baseline.status === "available") {
356
+ if (!digestPattern.test(instance.baseline.original) || !digestPattern.test(instance.baseline.rendered) || source.digest !== instance.baseline.original) throw new Error("Invalid template instance baseline digests.");
357
+ } else if (instance.baseline.status !== "unverified" || instance.baseline.reason !== "source-unavailable") throw new Error("Invalid template instance baseline status.");
358
+ }
359
+ function parseRegistry(content) {
360
+ const registry = JSON.parse(content);
361
+ onlyFields(registry, ["schemaVersion", "instances"]);
362
+ if (registry?.schemaVersion !== 1 || !Array.isArray(registry.instances)) throw new Error("Unsupported template instance registry schema.");
363
+ const ids = /* @__PURE__ */ new Set();
364
+ const targets = /* @__PURE__ */ new Set();
365
+ for (const instance of registry.instances) {
366
+ validateInstance(instance);
367
+ if (ids.has(instance.id) || targets.has(instance.target)) throw new Error("Duplicate template instance identity or target in registry.");
368
+ if ([...targets].some((target) => target.startsWith(`${instance.target}/`) || instance.target.startsWith(`${target}/`))) throw new Error("Overlapping template instance targets in registry.");
369
+ ids.add(instance.id);
370
+ targets.add(instance.target);
371
+ }
372
+ return registry;
373
+ }
374
+ //#endregion
375
+ //#region src/instances/snapshot.ts
376
+ const ignoredDirectories = /* @__PURE__ */ new Set([".git", ".repoctl"]);
377
+ function snapshotDigest(snapshot) {
378
+ return createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
379
+ }
380
+ function validateSnapshot(value) {
381
+ const snapshot = value;
382
+ if (snapshot?.schemaVersion !== 1 || !Array.isArray(snapshot.files) || !Array.isArray(snapshot.directories)) throw new Error("Invalid template baseline snapshot.");
383
+ const seen = /* @__PURE__ */ new Set();
384
+ let previous = "";
385
+ for (const directory of snapshot.directories) {
386
+ portableRelativePath(directory);
387
+ if (seen.has(directory) || directory < previous) throw new Error("Invalid or non-canonical template baseline directories.");
388
+ seen.add(directory);
389
+ previous = directory;
390
+ }
391
+ previous = "";
392
+ let bytes = 0;
393
+ for (const file of snapshot.files) {
394
+ portableRelativePath(file.path);
395
+ if (typeof file.content !== "string" || typeof file.executable !== "boolean" || Buffer.from(file.content, "base64").toString("base64") !== file.content || seen.has(file.path) || file.path < previous) throw new Error("Invalid or non-canonical template baseline files.");
396
+ seen.add(file.path);
397
+ previous = file.path;
398
+ bytes += Buffer.byteLength(file.content, "base64");
399
+ if (bytes > 33554432) throw new Error("Template baseline exceeds the supported snapshot size.");
400
+ }
401
+ }
402
+ /** Capture only template deliverables; never follow symbolic links or collect repository credentials. */
403
+ async function captureTemplateSnapshot(directory) {
404
+ const rootStat = await fs.lstat(directory);
405
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error(`Template source must be a real directory: ${directory}`);
406
+ const snapshot = {
407
+ schemaVersion: 1,
408
+ files: [],
409
+ directories: []
410
+ };
411
+ let bytes = 0;
412
+ const visit = async (current) => {
413
+ for (const entry of await fs.readdir(current, { withFileTypes: true })) {
414
+ const file = path.join(current, entry.name);
415
+ if (ignoredDirectories.has(entry.name) || shouldSkipTemplatePath(directory, file)) continue;
416
+ if (entry.isSymbolicLink() || !entry.isDirectory() && !entry.isFile()) throw new Error(`Unsupported link or special file in template: ${path.relative(directory, file)}`);
417
+ if (entry.isDirectory()) {
418
+ snapshot.directories.push(portableRelativePath(path.relative(directory, file).split(path.sep).join("/")));
419
+ if (snapshot.directories.length >= 1e4) throw new Error("Template baseline exceeds the supported directory count.");
420
+ await visit(file);
421
+ } else {
422
+ const content = await fs.readFile(file);
423
+ bytes += content.byteLength;
424
+ if (bytes > 33554432 || snapshot.files.length >= 1e4) throw new Error("Template baseline exceeds the supported snapshot size.");
425
+ const relative = path.relative(directory, file).split(path.sep).join("/");
426
+ snapshot.files.push({
427
+ path: portableRelativePath(relative),
428
+ content: content.toString("base64"),
429
+ executable: ((await fs.stat(file)).mode & 73) !== 0
430
+ });
431
+ }
432
+ }
433
+ };
434
+ await visit(directory);
435
+ snapshot.files.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
436
+ snapshot.directories.sort();
437
+ return snapshot;
438
+ }
439
+ function compareTemplateSnapshots(baseline, current) {
440
+ const entries = (snapshot) => new Map([...snapshot.files.map((file) => [file.path, JSON.stringify([file.content, file.executable])]), ...snapshot.directories.map((directory) => [directory, "directory"])]);
441
+ const before = entries(baseline);
442
+ const after = entries(current);
443
+ return [.../* @__PURE__ */ new Set([...before.keys(), ...after.keys()])].sort().flatMap((file) => {
444
+ if (before.get(file) === after.get(file)) return [];
445
+ return [{
446
+ path: file,
447
+ status: !before.has(file) ? "added" : !after.has(file) ? "deleted" : "modified"
448
+ }];
212
449
  });
213
- if (!templateKeys.length) return;
214
- const selections = new Set(templateKeys);
215
- for (const template of templateChoices$1) {
216
- if (!selections.has(template.key)) continue;
217
- await scaffoldTemplate({
218
- sourceDir: path.join(templatesDir$1, template.source),
219
- targetDir: path.join(targetDir, template.target)
450
+ }
451
+ async function writeTemplateSnapshot(snapshot, targetDir) {
452
+ validateSnapshot(snapshot);
453
+ await fs.mkdir(targetDir);
454
+ try {
455
+ for (const directory of snapshot.directories) await fs.mkdir(path.join(targetDir, ...directory.split("/")), { recursive: true });
456
+ for (const file of snapshot.files) {
457
+ const target = path.join(targetDir, ...file.path.split("/"));
458
+ await fs.mkdir(path.dirname(target), { recursive: true });
459
+ await fs.writeFile(target, Buffer.from(file.content, "base64"), {
460
+ flag: "wx",
461
+ mode: file.executable ? 493 : 420
462
+ });
463
+ }
464
+ } catch (error) {
465
+ await fs.rm(targetDir, {
466
+ recursive: true,
467
+ force: true
220
468
  });
469
+ throw error;
470
+ }
471
+ }
472
+ //#endregion
473
+ //#region src/instances/source.ts
474
+ async function readTemplatePackageVersion(sourcePackageDir = packageDir) {
475
+ const stat = await fs.lstat(sourcePackageDir);
476
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Historical template package root must be a real directory, not a link.");
477
+ const manifestPath = await safeInstancePath(sourcePackageDir, "package.json");
478
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
479
+ if (manifest.name !== "@icebreakers/monorepo-templates" || !exactVersionPattern.test(manifest.version ?? "")) throw new Error("Historical source must be an extracted @icebreakers/monorepo-templates package with an exact version.");
480
+ return manifest.version;
481
+ }
482
+ async function prepareTemplateInstanceSource(sourceDir, historicalPackageDir) {
483
+ const sourcePackageDir = historicalPackageDir ?? packageDir;
484
+ if (historicalPackageDir) {
485
+ await readTemplatePackageVersion(historicalPackageDir);
486
+ const root = await fs.realpath(historicalPackageDir);
487
+ const lexical = path.resolve(sourceDir).startsWith(`${path.resolve(historicalPackageDir)}${path.sep}`) ? path.resolve(historicalPackageDir) : root;
488
+ sourceDir = await safeInstancePath(historicalPackageDir, path.relative(lexical, path.resolve(sourceDir)).split(path.sep).join("/"));
221
489
  }
490
+ if (!historicalPackageDir && path.resolve(sourceDir).startsWith(`${path.resolve(templatesDir)}${path.sep}`)) await ensureTemplateAssetsPrepared();
491
+ const relative = path.relative(await fs.realpath(sourcePackageDir), await fs.realpath(sourceDir)).split(path.sep).join("/");
492
+ const packaged = relative.startsWith("templates/") && !relative.includes("../");
493
+ if (historicalPackageDir && !packaged) throw new Error("Historical template source escapes the selected package.");
494
+ if (packaged) await safeInstancePath(sourcePackageDir, relative);
495
+ const snapshot = await captureTemplateSnapshot(sourceDir);
496
+ return {
497
+ source: packaged ? {
498
+ kind: "package",
499
+ packageName: "@icebreakers/monorepo-templates",
500
+ version: await readTemplatePackageVersion(sourcePackageDir),
501
+ templatePath: relative,
502
+ digest: snapshotDigest(snapshot)
503
+ } : {
504
+ kind: "snapshot",
505
+ templatePath: "template",
506
+ digest: snapshotDigest(snapshot)
507
+ },
508
+ snapshot
509
+ };
222
510
  }
223
511
  //#endregion
224
512
  //#region src/templates.ts
@@ -299,6 +587,453 @@ function suggestTemplateKey(key, options = {}) {
299
587
  return best.key;
300
588
  }
301
589
  //#endregion
590
+ //#region src/instances/store.ts
591
+ var TemplateRegistryCleanupError = class extends AggregateError {
592
+ paths;
593
+ constructor(paths, errors) {
594
+ super(errors, `Template registry cleanup failed; inspect retained metadata: ${paths.join(", ")}`);
595
+ this.paths = paths;
596
+ }
597
+ };
598
+ async function removeOwnedLock(lockPath, token) {
599
+ try {
600
+ if ((await fs.lstat(lockPath)).isFile() && await fs.readFile(lockPath, "utf8") === token) await fs.rm(lockPath);
601
+ } catch (error) {
602
+ if (error.code !== "ENOENT") throw error;
603
+ }
604
+ }
605
+ async function removeOwnedTemporary(filename, owner) {
606
+ if (!owner) return;
607
+ try {
608
+ const stat = await fs.lstat(filename, { bigint: true });
609
+ if (stat.isFile() && stat.dev === owner.dev && stat.ino === owner.ino) await fs.rm(filename);
610
+ } catch (error) {
611
+ if (error.code !== "ENOENT") throw error;
612
+ }
613
+ }
614
+ async function loadTemplateInstanceRegistry(workspaceDir) {
615
+ const file = await safeInstancePath(workspaceDir, templateRegistryPath);
616
+ return await exists(file) ? parseRegistry(await fs.readFile(file, "utf8")) : {
617
+ schemaVersion: 1,
618
+ instances: []
619
+ };
620
+ }
621
+ async function loadTemplateBaseline(workspaceDir, digest) {
622
+ if (!digestPattern.test(digest)) throw new Error("Invalid template baseline digest.");
623
+ const file = await safeInstancePath(workspaceDir, `${templateBaselineDirectory}/${digest}.json`);
624
+ const snapshot = JSON.parse(await fs.readFile(file, "utf8"));
625
+ validateSnapshot(snapshot);
626
+ if (snapshotDigest(snapshot) !== digest) throw new Error(`Template baseline integrity check failed: ${digest}`);
627
+ return snapshot;
628
+ }
629
+ async function mutateTemplateRegistry(workspaceDir, mutate, hooks) {
630
+ const file = await safeInstancePath(workspaceDir, templateRegistryPath);
631
+ const lockPath = await safeInstancePath(workspaceDir, ".repoctl/template-instances.lock");
632
+ await fs.mkdir(path.dirname(file), { recursive: true });
633
+ let lock;
634
+ try {
635
+ lock = await fs.open(lockPath, "wx");
636
+ } catch (error) {
637
+ if (error.code === "EEXIST") throw new Error(`Template registry is locked: ${lockPath}. If its operation crashed, verify that no writer is active before removing this lock.`);
638
+ throw error;
639
+ }
640
+ const written = [];
641
+ const temporary = `${file}.${randomUUID()}.tmp`;
642
+ let temporaryOwner;
643
+ const lockToken = `${process.pid}:${randomUUID()}\n`;
644
+ let committed = false;
645
+ let result;
646
+ let failure;
647
+ try {
648
+ await lock.writeFile(lockToken);
649
+ const registry = await loadTemplateInstanceRegistry(workspaceDir);
650
+ const previous = JSON.stringify(registry);
651
+ const mutation = await mutate(registry);
652
+ result = mutation.result;
653
+ const snapshots = mutation.snapshots ?? {};
654
+ registry.instances.sort((a, b) => a.target < b.target ? -1 : a.target > b.target ? 1 : 0);
655
+ parseRegistry(JSON.stringify(registry));
656
+ if (JSON.stringify(registry) !== previous) {
657
+ for (const [digest, snapshot] of Object.entries(snapshots)) {
658
+ validateSnapshot(snapshot);
659
+ if (!digestPattern.test(digest) || snapshotDigest(snapshot) !== digest) throw new Error("Template snapshot digest does not match its content.");
660
+ const target = await safeInstancePath(workspaceDir, `${templateBaselineDirectory}/${digest}.json`);
661
+ if (await exists(target)) {
662
+ await loadTemplateBaseline(workspaceDir, digest);
663
+ continue;
664
+ }
665
+ await fs.mkdir(path.dirname(target), { recursive: true });
666
+ const handle = await fs.open(target, "wx");
667
+ written.push(target);
668
+ try {
669
+ await handle.writeFile(`${JSON.stringify(snapshot)}\n`);
670
+ } finally {
671
+ await handle.close();
672
+ }
673
+ }
674
+ const handle = await fs.open(temporary, "wx");
675
+ try {
676
+ temporaryOwner = await handle.stat({ bigint: true });
677
+ await handle.writeFile(`${JSON.stringify(registry, null, 2)}\n`);
678
+ } finally {
679
+ await handle.close();
680
+ }
681
+ await fs.rename(temporary, file);
682
+ }
683
+ committed = true;
684
+ await hooks?.committed?.();
685
+ } catch (error) {
686
+ failure = { error };
687
+ if (!committed) try {
688
+ await hooks?.rollback?.();
689
+ } catch (recoveryError) {
690
+ failure = { error: new AggregateError([error, recoveryError], `Template registry transaction failed and file recovery needs attention: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}. Preserve the pending recovery record.`) };
691
+ }
692
+ }
693
+ const paths = [];
694
+ const errors = [];
695
+ const cleanup = async (filename, operation) => {
696
+ try {
697
+ await operation();
698
+ } catch (error) {
699
+ paths.push(filename);
700
+ errors.push(error);
701
+ }
702
+ };
703
+ await cleanup(temporary, async () => await removeOwnedTemporary(temporary, temporaryOwner));
704
+ if (!committed) await Promise.all(written.map((target) => cleanup(target, async () => await fs.rm(target, { force: true }))));
705
+ await cleanup(lockPath, async () => await lock.close());
706
+ await cleanup(lockPath, async () => await removeOwnedLock(lockPath, lockToken));
707
+ if (paths.length) {
708
+ const cleanupError = new TemplateRegistryCleanupError([...new Set(paths)].sort(), errors);
709
+ failure = { error: failure ? new AggregateError([failure.error, cleanupError], `${failure.error instanceof Error ? failure.error.message : "Template registry transaction failed"}. ${cleanupError.message}`) : cleanupError };
710
+ }
711
+ if (failure) throw failure.error;
712
+ return result;
713
+ }
714
+ function canVerifyTemplateInstance(existing, next) {
715
+ const identity = (instance) => JSON.stringify({
716
+ target: instance.target,
717
+ template: instance.template,
718
+ profile: instance.generator.profile,
719
+ parameters: instance.parameters,
720
+ source: {
721
+ kind: instance.source.kind,
722
+ packageName: instance.source.packageName,
723
+ version: instance.source.version,
724
+ templatePath: instance.source.templatePath,
725
+ remote: instance.source.remote
726
+ }
727
+ });
728
+ return existing.baseline.status === "unverified" && next.baseline.status === "available" && identity(existing) === identity(next);
729
+ }
730
+ /** Generated IDs can opt into collision allocation under the lock; retries retain the existing target identity. */
731
+ async function registerTemplateInstances(workspaceDir, drafts, precondition, options = {}) {
732
+ return mutateTemplateRegistry(workspaceDir, async (registry) => {
733
+ await precondition?.(registry);
734
+ const registered = [];
735
+ const snapshots = {};
736
+ for (const draft of drafts) {
737
+ validateInstance(draft.instance);
738
+ const target = await safeInstancePath(workspaceDir, draft.instance.target);
739
+ const canonicalTarget = instanceRelativePath(await fs.realpath(workspaceDir), await fs.realpath(target));
740
+ if (canonicalTarget !== draft.instance.target) throw new Error(`Template instance target must use its canonical filesystem path: ${canonicalTarget}`);
741
+ const existing = registry.instances.find((instance) => instance.target === draft.instance.target);
742
+ if (existing) {
743
+ if (canVerifyTemplateInstance(existing, draft.instance)) {
744
+ Object.assign(existing, {
745
+ ...draft.instance,
746
+ id: existing.id,
747
+ provenance: existing.provenance
748
+ });
749
+ Object.assign(snapshots, draft.snapshots);
750
+ registered.push(existing);
751
+ continue;
752
+ }
753
+ if (JSON.stringify({
754
+ ...existing,
755
+ id: "",
756
+ provenance: ""
757
+ }) !== JSON.stringify({
758
+ ...draft.instance,
759
+ id: "",
760
+ provenance: ""
761
+ })) throw new Error(`Template target is already registered with different provenance: ${draft.instance.target}`);
762
+ registered.push(existing);
763
+ continue;
764
+ }
765
+ if (registry.instances.some((instance) => instance.target.startsWith(`${draft.instance.target}/`) || draft.instance.target.startsWith(`${instance.target}/`))) throw new Error(`Template target overlaps an existing instance: ${draft.instance.target}`);
766
+ let id = draft.instance.id;
767
+ if (!options.allocateIdOnConflict && registry.instances.some((instance) => instance.id === id)) throw new Error(`Template instance identity is already registered at a different target: ${id}`);
768
+ for (let attempt = 1; registry.instances.some((instance) => instance.id === id); attempt++) id = createHash("sha256").update(JSON.stringify([
769
+ draft.instance.id,
770
+ draft.instance.target,
771
+ attempt
772
+ ])).digest("hex").slice(0, 24);
773
+ const instance = {
774
+ ...draft.instance,
775
+ id
776
+ };
777
+ registry.instances.push(instance);
778
+ Object.assign(snapshots, draft.snapshots);
779
+ registered.push(instance);
780
+ }
781
+ for (const instance of registered) if (instance.baseline.status === "available") {
782
+ for (const digest of [instance.baseline.original, instance.baseline.rendered]) if (!snapshots[digest]) await loadTemplateBaseline(workspaceDir, digest);
783
+ }
784
+ return {
785
+ result: registered,
786
+ snapshots
787
+ };
788
+ }, options);
789
+ }
790
+ //#endregion
791
+ //#region src/instances/operations.ts
792
+ function excludeSnapshotPaths(snapshot, excludedPaths) {
793
+ const excluded = (file) => excludedPaths.some((item) => file === item || file.startsWith(`${item}/`));
794
+ return {
795
+ ...snapshot,
796
+ files: snapshot.files.filter((file) => !excluded(file.path)),
797
+ directories: snapshot.directories.filter((directory) => !excluded(directory))
798
+ };
799
+ }
800
+ function templateInstanceId(target, template) {
801
+ return createHash("sha256").update(JSON.stringify([target, template])).digest("hex").slice(0, 24);
802
+ }
803
+ /** Reserve a new directory only when no existing instance owns its path. */
804
+ async function createTemplateInstanceTarget(workspaceDir, relative) {
805
+ const target = await safeInstancePath(workspaceDir, relative);
806
+ const owner = (await loadTemplateInstanceRegistry(workspaceDir)).instances.find((instance) => instance.target === relative || instance.target.startsWith(`${relative}/`) || relative.startsWith(`${instance.target}/`));
807
+ if (owner) throw new Error(`Template target is already owned by retained instance ${owner.id} at ${owner.target}: ${relative}. Inspect it with repo templates instances ${owner.id} --json. Removing a directory retains its provenance and baselines. Restore the original project from version control or a backup, explicitly relocate a verified moved instance, or choose a different unowned path for a new project.`);
808
+ await fs.mkdir(path.dirname(target), { recursive: true });
809
+ await fs.mkdir(target);
810
+ return target;
811
+ }
812
+ async function prepareGeneratedTemplateInstance(options) {
813
+ const requested = instanceRelativePath(options.workspaceDir, options.targetDir);
814
+ const safeTarget = await safeInstancePath(options.workspaceDir, requested);
815
+ const target = instanceRelativePath(await fs.realpath(options.workspaceDir), await fs.realpath(safeTarget));
816
+ const excludedPaths = normalizeTemplateExclusions(options.excludedPaths ?? []);
817
+ const rendered = excludeSnapshotPaths(await captureTemplateSnapshot(safeTarget), excludedPaths);
818
+ const originalDigest = snapshotDigest(options.preparedSource.snapshot);
819
+ const renderedDigest = snapshotDigest(rendered);
820
+ return {
821
+ instance: {
822
+ id: templateInstanceId(target, options.template),
823
+ target,
824
+ template: options.template,
825
+ provenance: "created",
826
+ source: options.preparedSource.source,
827
+ generator: {
828
+ profile: options.profile,
829
+ version: options.generatorVersion ?? await readTemplatePackageVersion()
830
+ },
831
+ parameters: generationParameters(options.parameters),
832
+ baseline: {
833
+ status: "available",
834
+ original: originalDigest,
835
+ rendered: renderedDigest
836
+ },
837
+ ...excludedPaths.length ? { excludedPaths } : {}
838
+ },
839
+ snapshots: {
840
+ [originalDigest]: options.preparedSource.snapshot,
841
+ [renderedDigest]: rendered
842
+ }
843
+ };
844
+ }
845
+ async function recordGeneratedTemplateInstance(options) {
846
+ const draft = await prepareGeneratedTemplateInstance(options);
847
+ return (await registerTemplateInstances(options.workspaceDir, [draft], void 0, { allocateIdOnConflict: true }))[0];
848
+ }
849
+ async function listTemplateInstances(workspaceDir) {
850
+ const registry = await loadTemplateInstanceRegistry(workspaceDir);
851
+ return Promise.all(registry.instances.map(async (instance) => {
852
+ let targetStatus = "unsafe";
853
+ try {
854
+ const target = await safeInstancePath(workspaceDir, instance.target);
855
+ targetStatus = await exists(target) ? (await fs.lstat(target)).isDirectory() ? "present" : "unsafe" : "missing";
856
+ } catch {}
857
+ let baselineStatus = instance.baseline.status;
858
+ if (instance.baseline.status === "available") try {
859
+ await loadTemplateBaseline(workspaceDir, instance.baseline.original);
860
+ await loadTemplateBaseline(workspaceDir, instance.baseline.rendered);
861
+ } catch {
862
+ baselineStatus = "unavailable";
863
+ }
864
+ return {
865
+ instance,
866
+ targetStatus,
867
+ baselineStatus
868
+ };
869
+ }));
870
+ }
871
+ async function rebuildTemplateInstanceBaseline(workspaceDir, id, targetDir, layer = "rendered") {
872
+ const instance = (await loadTemplateInstanceRegistry(workspaceDir)).instances.find((item) => item.id === id || item.target === id);
873
+ if (!instance) throw new Error(`Unknown template instance: ${id}`);
874
+ if (instance.baseline.status !== "available") throw new Error(`Template instance ${instance.target} has no verified baseline; reliable upgrades are unavailable.`);
875
+ await writeTemplateSnapshot(await loadTemplateBaseline(workspaceDir, instance.baseline[layer]), targetDir);
876
+ return instance;
877
+ }
878
+ /** Relocation never guesses based on a package name; all retained baseline files must identify the instance. */
879
+ async function relocateTemplateInstance(workspaceDir, id, target, apply = false) {
880
+ const destination = await safeInstancePath(workspaceDir, target);
881
+ target = instanceRelativePath(await fs.realpath(workspaceDir), await fs.realpath(destination));
882
+ const inspect = async () => {
883
+ const registry = await loadTemplateInstanceRegistry(workspaceDir);
884
+ const instance = registry.instances.find((item) => item.id === id || item.target === id);
885
+ if (!instance) throw new Error(`Unknown template instance: ${id}`);
886
+ if (instance.target === target) return {
887
+ instance,
888
+ target,
889
+ changed: false
890
+ };
891
+ if (await exists(await safeInstancePath(workspaceDir, instance.target))) throw new Error("The registered target still exists; relocation requires a missing original target.");
892
+ if (instance.baseline.status !== "available") throw new Error("Cannot verify relocation without an available baseline.");
893
+ const baseline = excludeSnapshotPaths(await loadTemplateBaseline(workspaceDir, instance.baseline.rendered), instance.excludedPaths ?? []);
894
+ if (snapshotDigest(excludeSnapshotPaths(await captureTemplateSnapshot(await safeInstancePath(workspaceDir, target)), instance.excludedPaths ?? [])) !== snapshotDigest(baseline)) throw new Error("Relocation cannot verify this destination as the same instance: its files differ from the retained baseline.");
895
+ if (registry.instances.some((item) => item.id !== instance.id && (item.target === target || item.target.startsWith(`${target}/`) || target.startsWith(`${item.target}/`)))) throw new Error("Relocation target overlaps a registered instance.");
896
+ return {
897
+ instance,
898
+ target,
899
+ changed: true
900
+ };
901
+ };
902
+ if (!apply) return {
903
+ ...await inspect(),
904
+ applied: false
905
+ };
906
+ return mutateTemplateRegistry(workspaceDir, async (registry) => {
907
+ const result = await inspect();
908
+ if (result.changed) registry.instances.find((item) => item.id === result.instance.id).target = target;
909
+ return { result: {
910
+ ...result,
911
+ applied: true
912
+ } };
913
+ });
914
+ }
915
+ //#endregion
916
+ //#region src/instances/workspace.ts
917
+ /** Call after all scaffold and root-manifest transformations have succeeded. */
918
+ async function recordWorkspaceTemplateInstances(workspaceDir, templateKeys, templatesDir$2 = templatesDir, originals) {
919
+ try {
920
+ const selected = new Set(templateKeys);
921
+ const drafts = [];
922
+ for (const template of getTemplateChoices()) {
923
+ if (!selected.has(template.key)) continue;
924
+ const preparedSource = await prepareTemplateInstanceSource(path.join(templatesDir$2, template.source));
925
+ if (originals?.has(template.key) && snapshotDigest(originals.get(template.key).snapshot) !== snapshotDigest(preparedSource.snapshot)) throw new Error(`Template source changed during creation: ${template.key}. Generated files remain at ${workspaceDir}; no successful registration was written.`);
926
+ drafts.push(await prepareGeneratedTemplateInstance({
927
+ workspaceDir,
928
+ targetDir: path.join(workspaceDir, template.target),
929
+ template: template.key,
930
+ preparedSource,
931
+ profile: "workspace-copy-v1"
932
+ }));
933
+ }
934
+ if (!drafts.length) return [];
935
+ return await registerTemplateInstances(workspaceDir, drafts, void 0, { allocateIdOnConflict: true });
936
+ } catch (error) {
937
+ throw new Error(`Scaffolded files remain at ${workspaceDir}, but template registration did not complete. Registry recovery path: ${path.join(workspaceDir, ".repoctl/template-instances.json")}. Inspect the error and use repo templates link for each generated target.`, { cause: error });
938
+ }
939
+ }
940
+ //#endregion
941
+ //#region src/scaffold.ts
942
+ async function isEmptyDir(dir) {
943
+ try {
944
+ return (await fs.readdir(dir)).length === 0;
945
+ } catch (error) {
946
+ if (error && error.code === "ENOENT") return true;
947
+ throw error;
948
+ }
949
+ }
950
+ async function prepareTargetDir(dir, options = {}) {
951
+ if (await isEmptyDir(dir)) {
952
+ await fs.mkdir(dir, { recursive: true });
953
+ return;
954
+ }
955
+ if (!options.force) throw new Error(`Target directory ${dir} is not empty. Pass --force to overwrite.`);
956
+ await fs.rm(dir, {
957
+ recursive: true,
958
+ force: true
959
+ });
960
+ await fs.mkdir(dir, { recursive: true });
961
+ }
962
+ async function copyDirContents(sourceDir, targetDir, options = {}) {
963
+ const rootDir = options.rootDir ?? sourceDir;
964
+ const filter = options.filter ?? ((src) => !shouldSkipTemplatePath(rootDir, src));
965
+ const renameEntry = options.renameEntry ?? toWorkspaceGitignorePath;
966
+ const skipRoot = new Set(options.skipRootBasenames ?? []);
967
+ await fs.mkdir(targetDir, { recursive: true });
968
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
969
+ for (const entry of entries) {
970
+ if (sourceDir === rootDir && skipRoot.has(entry.name)) continue;
971
+ const from = path.join(sourceDir, entry.name);
972
+ if (!filter(from)) continue;
973
+ const targetName = renameEntry(entry.name);
974
+ const to = path.join(targetDir, targetName);
975
+ if (entry.isDirectory()) {
976
+ const nestedOptions = {
977
+ rootDir,
978
+ filter,
979
+ renameEntry
980
+ };
981
+ if (options.skipRootBasenames) nestedOptions.skipRootBasenames = options.skipRootBasenames;
982
+ await copyDirContents(from, to, { ...nestedOptions });
983
+ continue;
984
+ }
985
+ if (entry.isSymbolicLink()) {
986
+ const link = await fs.readlink(from);
987
+ await fs.symlink(link, to);
988
+ continue;
989
+ }
990
+ await fs.cp(from, to);
991
+ }
992
+ }
993
+ function isDefaultTemplatesPath(sourceDir) {
994
+ const resolvedSource = path.resolve(sourceDir);
995
+ const resolvedTemplatesRoot = path.resolve(templatesDir);
996
+ return resolvedSource === resolvedTemplatesRoot || resolvedSource.startsWith(`${resolvedTemplatesRoot}${path.sep}`);
997
+ }
998
+ async function scaffoldTemplate(options) {
999
+ const { sourceDir, targetDir, ensureTargetDir = true, filter, renameEntry, skipRootBasenames } = options;
1000
+ if (isDefaultTemplatesPath(sourceDir)) await ensureTemplateAssetsPrepared();
1001
+ if (ensureTargetDir) await fs.mkdir(targetDir, { recursive: true });
1002
+ const copyOptions = { rootDir: sourceDir };
1003
+ if (filter) copyOptions.filter = filter;
1004
+ if (renameEntry) copyOptions.renameEntry = renameEntry;
1005
+ if (skipRootBasenames) copyOptions.skipRootBasenames = skipRootBasenames;
1006
+ await copyDirContents(sourceDir, targetDir, copyOptions);
1007
+ }
1008
+ async function scaffoldWorkspace(options) {
1009
+ const { targetDir, templateKeys = [], assetsDir: assetsDir$1 = assetsDir, templatesDir: templatesDir$1 = templatesDir, includeAssets = true, targetMode = "prepare", force = false } = options;
1010
+ if (includeAssets && path.resolve(assetsDir$1) === path.resolve(assetsDir) || templateKeys.length > 0 && path.resolve(templatesDir$1) === path.resolve(templatesDir)) await ensureTemplateAssetsPrepared();
1011
+ if (targetMode === "prepare") await prepareTargetDir(targetDir, { force });
1012
+ else if (targetMode === "ensure") await fs.mkdir(targetDir, { recursive: true });
1013
+ if (includeAssets) await copyDirContents(assetsDir$1, targetDir, {
1014
+ rootDir: assetsDir$1,
1015
+ renameEntry: toWorkspaceAssetPath
1016
+ });
1017
+ if (!templateKeys.length) {
1018
+ await options.afterScaffold?.();
1019
+ return;
1020
+ }
1021
+ const selections = new Set(templateKeys);
1022
+ const originals = /* @__PURE__ */ new Map();
1023
+ for (const template of templateChoices$1) {
1024
+ if (!selections.has(template.key)) continue;
1025
+ const from = path.join(templatesDir$1, template.source);
1026
+ const to = path.join(targetDir, template.target);
1027
+ originals.set(template.key, await prepareTemplateInstanceSource(from));
1028
+ await scaffoldTemplate({
1029
+ sourceDir: from,
1030
+ targetDir: to
1031
+ });
1032
+ }
1033
+ await options.afterScaffold?.();
1034
+ await recordWorkspaceTemplateInstances(targetDir, templateKeys, templatesDir$1, originals);
1035
+ }
1036
+ //#endregion
302
1037
  //#region src/utils/command.ts
303
1038
  async function runCommand(command, args, options = {}) {
304
1039
  const result = await exec(command, args, {
@@ -318,6 +1053,334 @@ async function execaCommand(command, options = {}) {
318
1053
  return { exitCode: result.code };
319
1054
  }
320
1055
  //#endregion
1056
+ //#region src/instances/move.ts
1057
+ function fingerprint(registry) {
1058
+ const instances = [...registry.instances].sort((a, b) => a.target < b.target ? -1 : a.target > b.target ? 1 : 0);
1059
+ return createHash("sha256").update(JSON.stringify({
1060
+ ...registry,
1061
+ instances
1062
+ })).digest("hex");
1063
+ }
1064
+ function inside(target, parent) {
1065
+ return target === parent || target.startsWith(`${parent}/`);
1066
+ }
1067
+ function inspectMove(registry, from, to) {
1068
+ portableRelativePath(from);
1069
+ portableRelativePath(to);
1070
+ const owner = registry.instances.find((instance) => instance.target !== from && inside(from.toLowerCase(), instance.target.toLowerCase()));
1071
+ if (owner) throw new Error(`Workspace ${from} belongs to parent template instance ${owner.id} (${owner.target}); moving only part of an instance requires an explicit ownership review.`);
1072
+ if (registry.instances.some((instance) => !inside(instance.target, from) && (inside(to.toLowerCase(), instance.target.toLowerCase()) || inside(instance.target.toLowerCase(), to.toLowerCase())))) throw new Error(`Template move destination overlaps a registered instance, including retained missing records: ${to}`);
1073
+ const relocations = registry.instances.filter((instance) => inside(instance.target, from)).map((instance) => ({
1074
+ id: instance.id,
1075
+ from: instance.target,
1076
+ to: to + instance.target.slice(from.length)
1077
+ })).sort((a, b) => a.id.localeCompare(b.id));
1078
+ const after = {
1079
+ ...registry,
1080
+ instances: registry.instances.map((instance) => ({
1081
+ ...instance,
1082
+ target: relocations.find((item) => item.id === instance.id)?.to ?? instance.target
1083
+ }))
1084
+ };
1085
+ parseRegistry(JSON.stringify(after));
1086
+ for (const relocation of relocations) if (after.instances.some((instance) => instance.id !== relocation.id && (inside(instance.target.toLowerCase(), relocation.to.toLowerCase()) || inside(relocation.to.toLowerCase(), instance.target.toLowerCase())))) throw new Error(`Template move destination overlaps a registered instance, including retained missing records: ${relocation.to}`);
1087
+ return {
1088
+ from,
1089
+ to,
1090
+ beforeHash: fingerprint(registry),
1091
+ afterHash: fingerprint(after),
1092
+ relocations
1093
+ };
1094
+ }
1095
+ function reversePlan(plan) {
1096
+ return {
1097
+ from: plan.to,
1098
+ to: plan.from,
1099
+ beforeHash: plan.afterHash,
1100
+ afterHash: plan.beforeHash,
1101
+ relocations: plan.relocations.map((item) => ({
1102
+ id: item.id,
1103
+ from: item.to,
1104
+ to: item.from
1105
+ }))
1106
+ };
1107
+ }
1108
+ /** Preview target changes without treating business edits as a new template baseline. */
1109
+ async function planTemplateInstanceMove(workspaceDir, from, to) {
1110
+ const plan = inspectMove(await loadTemplateInstanceRegistry(workspaceDir), from, to);
1111
+ for (const relocation of plan.relocations) await safeInstancePath(workspaceDir, relocation.to);
1112
+ return plan;
1113
+ }
1114
+ /** Hold the registry lock through file mutation, registry commit and any rollback, including moves with no registered instances. */
1115
+ async function moveTemplateInstances(workspaceDir, plan, hooks, replay = false) {
1116
+ let committed = false;
1117
+ try {
1118
+ await mutateTemplateRegistry(workspaceDir, async (registry) => {
1119
+ const expected = replay ? plan.afterHash : plan.beforeHash;
1120
+ if (fingerprint(registry) !== expected) throw new Error("Template instance registry changed before the workspace move; preview again.");
1121
+ const current = replay ? reversePlan(inspectMove(registry, plan.to, plan.from)) : inspectMove(registry, plan.from, plan.to);
1122
+ if (!isDeepStrictEqual(current, plan)) throw new Error("Template instance move plan changed or is invalid; preview again.");
1123
+ for (const relocation of plan.relocations) await safeInstancePath(workspaceDir, relocation.to);
1124
+ await hooks.apply();
1125
+ if (!replay) for (const relocation of plan.relocations) registry.instances.find((instance) => instance.id === relocation.id).target = relocation.to;
1126
+ if (fingerprint(registry) !== plan.afterHash) throw new Error("Template instance registry does not match the reviewed move result.");
1127
+ return { result: void 0 };
1128
+ }, {
1129
+ rollback: hooks.rollback,
1130
+ committed: async () => {
1131
+ committed = true;
1132
+ await hooks.committed();
1133
+ }
1134
+ });
1135
+ return [];
1136
+ } catch (error) {
1137
+ if (committed && error instanceof TemplateRegistryCleanupError) return error.paths;
1138
+ throw error;
1139
+ }
1140
+ }
1141
+ //#endregion
1142
+ //#region src/instances/replace.ts
1143
+ /** Replace exactly one instance, rolling file mutations back before releasing the registry lock on failure. */
1144
+ async function replaceTemplateInstance(workspaceDir, before, draft, hooks) {
1145
+ validateInstance(draft.instance);
1146
+ const identity = (instance) => JSON.stringify([
1147
+ instance.id,
1148
+ instance.target,
1149
+ instance.template,
1150
+ instance.provenance,
1151
+ instance.generator.profile
1152
+ ]);
1153
+ if (identity(before) !== identity(draft.instance) || draft.instance.baseline.status !== "available") throw new Error("An instance upgrade must preserve its identity and provide a reliable target baseline.");
1154
+ for (const [digest, snapshot] of Object.entries(draft.snapshots)) {
1155
+ validateSnapshot(snapshot);
1156
+ if (snapshotDigest(snapshot) !== digest) throw new Error("Replacement baseline digest does not match its content.");
1157
+ }
1158
+ return mutateTemplateRegistry(workspaceDir, async (registry) => {
1159
+ const index = registry.instances.findIndex((instance) => instance.id === before.id);
1160
+ if (index < 0 || JSON.stringify(registry.instances[index]) !== JSON.stringify(before)) throw new Error("Template instance changed before the transaction; preview again.");
1161
+ if (draft.instance.baseline.status === "available") {
1162
+ for (const digest of [draft.instance.baseline.original, draft.instance.baseline.rendered]) if (!draft.snapshots[digest]) await loadTemplateBaseline(workspaceDir, digest);
1163
+ }
1164
+ await hooks.apply();
1165
+ registry.instances[index] = draft.instance;
1166
+ return {
1167
+ result: draft.instance,
1168
+ snapshots: draft.snapshots
1169
+ };
1170
+ }, hooks);
1171
+ }
1172
+ //#endregion
1173
+ //#region src/parameters/manifest.ts
1174
+ const templateParameterManifestName = "repoctl.template.json";
1175
+ const packageSections = [
1176
+ "scripts",
1177
+ "dependencies",
1178
+ "devDependencies",
1179
+ "peerDependencies",
1180
+ "optionalDependencies"
1181
+ ];
1182
+ function paths(input, field) {
1183
+ if (!Array.isArray(input) || input.length > 1e3 || input.some((item) => typeof item !== "string") || new Set(input).size !== input.length) parameterError(field, "expected unique relative paths");
1184
+ for (const item of input) {
1185
+ portableRelativePath(item);
1186
+ if (/[*?{}[\]]/u.test(item) || item === "repoctl.template.json" || item === "package.json") parameterError(field, "use exact asset paths; package entries belong in conditions.package");
1187
+ }
1188
+ }
1189
+ function parseTemplateParameterManifest(value) {
1190
+ if (!isParameterRecord(value) || value["schemaVersion"] !== 1 || Object.keys(value).some((key) => ![
1191
+ "schemaVersion",
1192
+ "parameters",
1193
+ "interpolate",
1194
+ "conditions"
1195
+ ].includes(key))) parameterError("manifest", "expected schemaVersion 1 and supported declarative fields");
1196
+ validateTemplateParameterSchema(value["parameters"]);
1197
+ if (value["interpolate"] !== void 0) paths(value["interpolate"], "interpolate");
1198
+ if (value["conditions"] !== void 0) {
1199
+ if (!Array.isArray(value["conditions"]) || value["conditions"].length > 100) parameterError("conditions", "expected at most 100 conditions");
1200
+ for (const [index, item] of value["conditions"].entries()) {
1201
+ const field = `conditions[${index}]`;
1202
+ if (!isParameterRecord(item) || Object.keys(item).some((key) => ![
1203
+ "when",
1204
+ "files",
1205
+ "package"
1206
+ ].includes(key)) || !isParameterRecord(item["when"]) || Object.keys(item["when"]).some((key) => !["parameter", "equals"].includes(key)) || typeof item["when"]["parameter"] !== "string") parameterError(field, "expected a parameter equality condition");
1207
+ const definition = Object.hasOwn(value["parameters"], item["when"]["parameter"]) ? value["parameters"][item["when"]["parameter"]] : void 0;
1208
+ if (!definition || definition.sensitive || !parameterValueMatches(definition, item["when"]["equals"])) parameterError(`${field}.when`, "condition must compare a declared nonsensitive parameter with a value of its type");
1209
+ if (item["files"] !== void 0) paths(item["files"], `${field}.files`);
1210
+ if (item["package"] !== void 0) {
1211
+ if (!isParameterRecord(item["package"]) || Object.keys(item["package"]).some((key) => !packageSections.includes(key))) parameterError(`${field}.package`, "only scripts and dependency maps are supported");
1212
+ for (const [section, entries] of Object.entries(item["package"])) if (!isParameterRecord(entries) || Object.entries(entries).some(([key, value]) => !key || [
1213
+ "__proto__",
1214
+ "constructor",
1215
+ "prototype"
1216
+ ].includes(key) || typeof value !== "string")) parameterError(`${field}.package.${section}`, "expected a map of string entries");
1217
+ }
1218
+ if (item["files"] === void 0 && item["package"] === void 0) parameterError(field, "declare files or package entries");
1219
+ }
1220
+ }
1221
+ return value;
1222
+ }
1223
+ function readTemplateParameterManifest(snapshot) {
1224
+ const file = snapshot.files.find((file) => file.path === templateParameterManifestName);
1225
+ if (!file) return;
1226
+ let value;
1227
+ try {
1228
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(file.content, "base64")));
1229
+ } catch {
1230
+ parameterError("manifest", "repoctl.template.json must contain UTF-8 JSON");
1231
+ }
1232
+ return parseTemplateParameterManifest(value);
1233
+ }
1234
+ //#endregion
1235
+ //#region src/parameters/render.ts
1236
+ /** Expand declared text only. Binary files and all undeclared files remain byte-for-byte identical. */
1237
+ function renderTemplateParameters(source, manifest, parameters) {
1238
+ validateSnapshot(source);
1239
+ const result = {
1240
+ snapshot: {
1241
+ schemaVersion: 1,
1242
+ files: [],
1243
+ directories: []
1244
+ },
1245
+ sensitivePaths: [],
1246
+ files: [],
1247
+ package: []
1248
+ };
1249
+ const entries = /* @__PURE__ */ new Set([...source.files.map((file) => file.path), ...source.directories]);
1250
+ const omitted = /* @__PURE__ */ new Set();
1251
+ const within = (file, directory) => file === directory || file.startsWith(`${directory}/`);
1252
+ for (const file of manifest.interpolate ?? []) if (!source.files.some((entry) => entry.path === file)) parameterError("interpolate", `declared file does not exist: ${file}`);
1253
+ for (const condition of manifest.conditions ?? []) for (const file of condition.files ?? []) {
1254
+ if (!entries.has(file)) parameterError("conditions.files", `declared path does not exist: ${file}`);
1255
+ if (parameters.values[condition.when.parameter] !== condition.when.equals) omitted.add(file);
1256
+ }
1257
+ let outputBytes = source.files.filter((file) => file.path !== "repoctl.template.json" && ![...omitted].some((item) => within(file.path, item))).reduce((total, file) => total + Buffer.byteLength(file.content, "base64"), 0);
1258
+ function checkSize() {
1259
+ if (outputBytes > 33554432) parameterError("output", "rendered snapshot exceeds 32 MiB");
1260
+ }
1261
+ function interpolate(text, file, allowSensitive) {
1262
+ if (!allowSensitive) {
1263
+ outputBytes += Buffer.byteLength(text);
1264
+ checkSize();
1265
+ }
1266
+ return text.replace(/\{\{repoctl(-json)?:([a-z]\w*)\}\}/gu, (token, json, name) => {
1267
+ if (!Object.hasOwn(parameters.values, name)) parameterError(`values.${name}`, "an interpolated parameter is missing");
1268
+ if (parameters.sensitive.includes(name)) {
1269
+ if (!allowSensitive) parameterError(`values.${name}`, "sensitive inputs cannot enter package metadata");
1270
+ result.sensitivePaths.push(file);
1271
+ }
1272
+ const replacement = json ? JSON.stringify(parameters.values[name]) : String(parameters.values[name]);
1273
+ outputBytes += Buffer.byteLength(replacement) - Buffer.byteLength(token);
1274
+ checkSize();
1275
+ return replacement;
1276
+ });
1277
+ }
1278
+ for (const file of source.files) {
1279
+ if (file.path === "repoctl.template.json") continue;
1280
+ if ([...omitted].some((item) => within(file.path, item))) {
1281
+ result.files.push({
1282
+ path: file.path,
1283
+ status: "omit",
1284
+ sensitive: false
1285
+ });
1286
+ continue;
1287
+ }
1288
+ let content = Buffer.from(file.content, "base64");
1289
+ if (manifest.interpolate?.includes(file.path) && !content.includes(0)) {
1290
+ let text;
1291
+ try {
1292
+ text = new TextDecoder("utf-8", { fatal: true }).decode(content);
1293
+ } catch {}
1294
+ if (text !== void 0) content = Buffer.from(interpolate(text, file.path, true));
1295
+ }
1296
+ result.snapshot.files.push({
1297
+ ...file,
1298
+ content: content.toString("base64")
1299
+ });
1300
+ result.files.push({
1301
+ path: file.path,
1302
+ status: "include",
1303
+ sensitive: result.sensitivePaths.includes(file.path)
1304
+ });
1305
+ }
1306
+ result.snapshot.directories = source.directories.filter((directory) => ![...omitted].some((item) => within(directory, item)));
1307
+ const packageFile = result.snapshot.files.find((file) => file.path === "package.json");
1308
+ if (manifest.conditions?.some((condition) => condition.package)) {
1309
+ if (!packageFile) parameterError("conditions.package", "a package.json file is required");
1310
+ let pkg;
1311
+ try {
1312
+ const value = JSON.parse(Buffer.from(packageFile.content, "base64").toString("utf8"));
1313
+ if (!isParameterRecord(value)) throw new Error("invalid");
1314
+ pkg = value;
1315
+ } catch {
1316
+ parameterError("conditions.package", "package.json must be a JSON object");
1317
+ }
1318
+ const reserved = /* @__PURE__ */ new Set();
1319
+ for (const condition of manifest.conditions ?? []) for (const section of packageSections) for (const [name, template] of Object.entries(condition.package?.[section] ?? {})) {
1320
+ const identity = `${section}.${name}`;
1321
+ if (reserved.has(identity) || isParameterRecord(pkg[section]) && Object.hasOwn(pkg[section], name)) parameterError(`conditions.package.${identity}`, "conditional entries must have one owner and be absent from the base manifest");
1322
+ reserved.add(identity);
1323
+ const include = parameters.values[condition.when.parameter] === condition.when.equals;
1324
+ result.package.push({
1325
+ section,
1326
+ name,
1327
+ status: include ? "include" : "omit"
1328
+ });
1329
+ if (include) {
1330
+ if (pkg[section] !== void 0 && !isParameterRecord(pkg[section])) parameterError(`conditions.package.${section}`, "base manifest section must be an object");
1331
+ const values = pkg[section] ?? {};
1332
+ values[name] = interpolate(template, "package.json", false);
1333
+ pkg[section] = Object.fromEntries(Object.entries(values).sort(([a], [b]) => a.localeCompare(b)));
1334
+ }
1335
+ }
1336
+ packageFile.content = Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`).toString("base64");
1337
+ }
1338
+ result.sensitivePaths = [...new Set(result.sensitivePaths)].sort();
1339
+ validateSnapshot(result.snapshot);
1340
+ return result;
1341
+ }
1342
+ //#endregion
1343
+ //#region src/parameters/resolve.ts
1344
+ /** The same strict, synchronous resolver serves prompts, JSON input and local generators. */
1345
+ function resolveTemplateParameters(schema, input = {}) {
1346
+ validateTemplateParameterSchema(schema);
1347
+ if (!isParameterRecord(input)) parameterError("values", "expected an object");
1348
+ for (const name of Object.keys(input)) if (!Object.hasOwn(schema, name)) parameterError(`values.${name}`, "unknown parameter");
1349
+ const result = {
1350
+ values: {},
1351
+ report: {},
1352
+ retained: {},
1353
+ sensitive: []
1354
+ };
1355
+ for (const [name, definition] of Object.entries(schema)) {
1356
+ const value = Object.hasOwn(input, name) ? input[name] : definition.default;
1357
+ if (value === void 0) {
1358
+ if (definition.required) parameterError(`values.${name}`, "required parameter is missing");
1359
+ continue;
1360
+ }
1361
+ if (!parameterValueMatches(definition, value) || definition.required && value === "") parameterError(`values.${name}`, `expected ${definition.type}${definition.type === "enum" ? " option" : ""}`);
1362
+ result.values[name] = value;
1363
+ result.report[name] = definition.sensitive ? "[redacted]" : value;
1364
+ if (definition.sensitive) result.sensitive.push(name);
1365
+ else result.retained[name] = value;
1366
+ }
1367
+ return result;
1368
+ }
1369
+ /** Prompts only omitted inputs; supplied inputs are validated before any question is asked. */
1370
+ async function promptTemplateParameters(schema, input, prompt) {
1371
+ validateTemplateParameterSchema(schema);
1372
+ resolveTemplateParameters(Object.fromEntries(Object.entries(schema).map(([name, definition]) => [name, {
1373
+ ...definition,
1374
+ required: false
1375
+ }])), input);
1376
+ const values = { ...input };
1377
+ for (const [name, definition] of Object.entries(schema)) if (!Object.hasOwn(values, name)) {
1378
+ const value = await prompt(name, definition);
1379
+ if (value !== void 0) values[name] = value;
1380
+ }
1381
+ return resolveTemplateParameters(schema, values);
1382
+ }
1383
+ //#endregion
321
1384
  //#region src/index.ts
322
1385
  const templateChoices = getTemplateChoices();
323
1386
  const assetTargets = assetTargets$1;
@@ -326,4 +1389,4 @@ const templateSourceMap = Object.fromEntries(templateChoices.map((item) => [item
326
1389
  const templateTargetMap = Object.fromEntries(templateChoices.map((item) => [item.key, item.target]));
327
1390
  const templateMap = templateSourceMap;
328
1391
  //#endregion
329
- export { Command, assetTargets, assetsDir, checkbox, createTemplateCopyFilter, ensureTemplateAssetsPrepared, execaCommand, getAssetTargets, getTemplateChoice, getTemplateChoices, getTemplateDefinition, getTemplateKeys, getTemplateSource, getTemplateTarget, getWorkspacePackageManager, input, isGitignoreFile, isTemplateCategory, isTemplateKey, packageDir, prepareAssets, program, runCommand, scaffoldTemplate, scaffoldWorkspace, select, shouldSkipTemplatePath, skeletonDir, suggestTemplateKey, templateCategories, templateChoices, templateMap, templateSourceMap, templateTargetMap, templatesDir, toPublishGitignorePath, toWorkspaceAssetPath, toWorkspaceGitignorePath };
1392
+ export { Command, areTemplateAssetsPrepared, assetTargets, assetsDir, canVerifyTemplateInstance, captureTemplateSnapshot, checkbox, compareTemplateSnapshots, createTemplateCopyFilter, createTemplateInstanceTarget, ensureTemplateAssetsPrepared, execaCommand, generationParameters, getAssetTargets, getTemplateChoice, getTemplateChoices, getTemplateDefinition, getTemplateKeys, getTemplateSource, getTemplateTarget, getWorkspacePackageManager, input, instanceRelativePath, isExactTemplateVersion, isGitignoreFile, isTemplateCategory, isTemplateKey, listTemplateInstances, loadTemplateBaseline, loadTemplateInstanceRegistry, moveTemplateInstances, normalizeTemplateExclusions, packageDir, parseTemplateParameterManifest, password, planTemplateInstanceMove, prepareAssets, prepareGeneratedTemplateInstance, prepareTemplateInstanceSource, program, promptTemplateParameters, readTemplatePackageVersion, readTemplateParameterManifest, rebuildTemplateInstanceBaseline, recordGeneratedTemplateInstance, recordWorkspaceTemplateInstances, registerTemplateInstances, relocateTemplateInstance, renderTemplateParameters, replaceTemplateInstance, resolveTemplateParameters, runCommand, safeInstancePath, scaffoldTemplate, scaffoldWorkspace, select, shouldSkipTemplatePath, skeletonDir, snapshotDigest, suggestTemplateKey, templateBaselineDirectory, templateCategories, templateChoices, templateInstanceId, templateMap, templateParameterManifestName, templateRegistryPath, templateSourceMap, templateTargetMap, templatesDir, toPublishGitignorePath, toWorkspaceAssetPath, toWorkspaceGitignorePath, validateTemplateParameterSchema, writeTemplateSnapshot };