@ebay-dt/bonfire-node 0.2.0-alpha.0 → 0.2.0-alpha.11

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 (52) hide show
  1. package/README.md +18 -5
  2. package/dist/deployment-provider.d.ts +2 -1
  3. package/dist/deployment-provider.d.ts.map +1 -1
  4. package/dist/deployment-provider.js +2 -1
  5. package/dist/deployment-provider.js.map +1 -1
  6. package/dist/duplicate.d.ts.map +1 -1
  7. package/dist/duplicate.js +103 -47
  8. package/dist/duplicate.js.map +1 -1
  9. package/dist/git-repository.d.ts +16 -1
  10. package/dist/git-repository.d.ts.map +1 -1
  11. package/dist/git-repository.js +127 -1
  12. package/dist/git-repository.js.map +1 -1
  13. package/dist/github-cli.d.ts.map +1 -1
  14. package/dist/github-cli.js +3 -1
  15. package/dist/github-cli.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/internal/prototype-templates.d.ts +0 -1
  21. package/dist/internal/prototype-templates.d.ts.map +1 -1
  22. package/dist/internal/prototype-templates.js +0 -5
  23. package/dist/internal/prototype-templates.js.map +1 -1
  24. package/dist/lifecycle/deployment-worktree.d.ts +36 -0
  25. package/dist/lifecycle/deployment-worktree.d.ts.map +1 -0
  26. package/dist/lifecycle/deployment-worktree.js +503 -0
  27. package/dist/lifecycle/deployment-worktree.js.map +1 -0
  28. package/dist/lifecycle/local-prototype-lifecycle.d.ts +15 -1
  29. package/dist/lifecycle/local-prototype-lifecycle.d.ts.map +1 -1
  30. package/dist/lifecycle/local-prototype-lifecycle.js +738 -98
  31. package/dist/lifecycle/local-prototype-lifecycle.js.map +1 -1
  32. package/dist/lifecycle/preparation-marker.d.ts +12 -0
  33. package/dist/lifecycle/preparation-marker.d.ts.map +1 -0
  34. package/dist/lifecycle/preparation-marker.js +76 -0
  35. package/dist/lifecycle/preparation-marker.js.map +1 -0
  36. package/dist/lifecycle/prototype-path-policy.d.ts +18 -0
  37. package/dist/lifecycle/prototype-path-policy.d.ts.map +1 -0
  38. package/dist/lifecycle/prototype-path-policy.js +429 -0
  39. package/dist/lifecycle/prototype-path-policy.js.map +1 -0
  40. package/dist/lifecycle/repository-summary.d.ts +7 -1
  41. package/dist/lifecycle/repository-summary.d.ts.map +1 -1
  42. package/dist/lifecycle/repository-summary.js +20 -4
  43. package/dist/lifecycle/repository-summary.js.map +1 -1
  44. package/dist/lifecycle/repository-synchronization.d.ts +13 -8
  45. package/dist/lifecycle/repository-synchronization.d.ts.map +1 -1
  46. package/dist/lifecycle/repository-synchronization.js +680 -139
  47. package/dist/lifecycle/repository-synchronization.js.map +1 -1
  48. package/dist/lifecycle/runtime-context.d.ts +2 -0
  49. package/dist/lifecycle/runtime-context.d.ts.map +1 -1
  50. package/dist/lifecycle/runtime-context.js +5 -1
  51. package/dist/lifecycle/runtime-context.js.map +1 -1
  52. package/package.json +2 -2
package/README.md CHANGED
@@ -28,6 +28,17 @@ A competing mutation returns `repository-busy` immediately rather than waiting.
28
28
  The gate covers the supported local Node process only; coordination across
29
29
  multiple processes or machines is not provided.
30
30
 
31
+ Managed mode is the default: known-identity pre-start preparation and first
32
+ entry synchronize primary into the identity branch, and explicit Get updates
33
+ rechecks and applies synchronization. Ordinary create/edit/delete do not
34
+ silently synchronize. Full manual mode (`BF_GIT_MODE=manual` in the Next/CLI
35
+ host) preserves owned filesystem lifecycle and read-only local status, but
36
+ forbids Bonfire fetch, branch selection, commit, push, Save, Discard, and Deploy.
37
+ The older `BF_DISABLE_AUTO_BRANCH_SWITCH=true` flag only skips automatic
38
+ branch selection; it does not disable managed Git operations. See
39
+ [`docs/managed-git-lifecycle.md`](../../docs/managed-git-lifecycle.md) for
40
+ repository attention, scoped actions, and isolated deployment publication.
41
+
31
42
  ## Deployment provider
32
43
 
33
44
  Deployment is an optional development capability. Configure the generic provider
@@ -117,11 +128,13 @@ tracked files and removes untracked files within that path.
117
128
  mutations through one process-local operation gate. Edit updates only title,
118
129
  description, and `updatedAt` while preserving the existing ref/URL. Delete
119
130
  removes the complete owned prototype directory, including uncommitted files,
120
- after ownership and path validation. These operations prepare the authenticated
121
- identity branch when automatic switching is enabled;
122
- `BF_DISABLE_AUTO_BRANCH_SWITCH=true` disables every automatic switch and leaves
123
- the current branch untouched. Git remains behind the Node-owned repository
124
- adapter, and `simple-git` is not part of the public API.
131
+ after ownership and path validation. In managed mode these operations require
132
+ the authenticated identity branch and a safe repository state; they do not
133
+ prepare branches during ordinary
134
+ mutations. `BF_DISABLE_AUTO_BRANCH_SWITCH=true` skips automatic branch
135
+ preparation, but does not authorize mutations on a different branch. Git
136
+ remains behind the Node-owned repository adapter, and `simple-git` is not
137
+ part of the public API.
125
138
 
126
139
  This is a server-only host extension: it must not perform repository-wide
127
140
  projection, package installation, skills or MCP setup, or write outside the
@@ -6,6 +6,7 @@ import type { DeploymentReadyState } from "@ebay-dt/bonfire-core";
6
6
  export type DeploymentProviderResult = {
7
7
  url: string;
8
8
  deploymentId: string;
9
+ commitSha: string;
9
10
  readyState: DeploymentReadyState;
10
11
  };
11
12
  /** Server-side transport boundary for creating an application deployment. */
@@ -19,7 +20,7 @@ export type CreateDeploymentProviderOptions = {
19
20
  endpoint: string;
20
21
  timeoutMs?: number;
21
22
  };
22
- export type DeploymentProviderErrorKind = "timeout" | "service-failure" | "invalid-response" | "invalid-url" | "invalid-configuration";
23
+ export type DeploymentProviderErrorKind = "timeout" | "service-failure" | "transport-failure" | "invalid-response" | "invalid-url" | "invalid-configuration";
23
24
  /**
24
25
  * Safe provider failure classification. It intentionally carries no response
25
26
  * body, endpoint credentials, or unbounded service diagnostics.
@@ -1 +1 @@
1
- {"version":3,"file":"deployment-provider.d.ts","sourceRoot":"","sources":["../src/deployment-provider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAQlE;;;GAGG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,oBAAoB,CAAC;CAClC,CAAC;AAEF,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,KAAK,EAAE;QACZ,aAAa,EAAE,MAAM,CAAC;QACtB,KAAK,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAC;CACvC;AAED,MAAM,MAAM,+BAA+B,GAAG;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GACnC,SAAS,GACT,iBAAiB,GACjB,kBAAkB,GAClB,aAAa,GACb,uBAAuB,CAAC;AAE5B;;;GAGG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,QAAQ,CAAC,IAAI,EAAE,2BAA2B,CAAC;IAE3C,YAAY,IAAI,EAAE,2BAA2B,EAI5C;CACF;AA8KD,wBAAgB,wBAAwB,CAAC,EACvC,QAAQ,EAAE,kBAAkB,EAC5B,SAAS,EAAE,iBAAiB,GAC7B,EAAE,+BAA+B,GAAG,kBAAkB,CA2CtD","sourcesContent":["import type { DeploymentReadyState } from \"@ebay-dt/bonfire-core\";\nimport { isDeploymentReadyState } from \"@ebay-dt/bonfire-core\";\nimport { isRecord } from \"./internal/utils.js\";\n\nconst DEFAULT_TIMEOUT_MS = 120_000;\nconst MAX_RESPONSE_STRING_LENGTH = 2_000;\nconst MAX_RESPONSE_URL_LENGTH = 8_192;\n\n/**\n * The normalized result returned by a deployment provider. `url` is the\n * deployment-root URL; the lifecycle service adds the active prototype route.\n */\nexport type DeploymentProviderResult = {\n url: string;\n deploymentId: string;\n readyState: DeploymentReadyState;\n};\n\n/** Server-side transport boundary for creating an application deployment. */\nexport interface DeploymentProvider {\n deploy(input: {\n applicationId: string;\n login: string;\n }): Promise<DeploymentProviderResult>;\n}\n\nexport type CreateDeploymentProviderOptions = {\n endpoint: string;\n timeoutMs?: number;\n};\n\nexport type DeploymentProviderErrorKind =\n | \"timeout\"\n | \"service-failure\"\n | \"invalid-response\"\n | \"invalid-url\"\n | \"invalid-configuration\";\n\n/**\n * Safe provider failure classification. It intentionally carries no response\n * body, endpoint credentials, or unbounded service diagnostics.\n */\nexport class DeploymentProviderError extends Error {\n readonly kind: DeploymentProviderErrorKind;\n\n constructor(kind: DeploymentProviderErrorKind) {\n super(\"Deployment provider request failed\");\n this.name = \"DeploymentProviderError\";\n this.kind = kind;\n }\n}\n\n/**\n * Internal DT deployment success wire shape. The service diagnostics are\n * validated at the transport boundary but are not exposed as client data.\n */\ntype DeploymentServiceSuccessResponse = {\n repository: string;\n ref: string;\n commitSha: string;\n sourceFileCount: number;\n project: string;\n deployed: true;\n deploymentId: string;\n url: string;\n readyState: DeploymentReadyState;\n};\n\n/** Internal DT deployment failure wire shape. */\ntype DeploymentServiceFailureResponse = {\n repository: string;\n ref?: string;\n commitSha?: string;\n project?: string;\n stage?: string;\n deployed: false;\n statusCode: number;\n details: string;\n};\n\ntype DeploymentServiceResponse =\n DeploymentServiceSuccessResponse | DeploymentServiceFailureResponse;\n\nfunction isBoundedString(\n value: unknown,\n options: { allowEmpty?: boolean; maxLength?: number } = {},\n): value is string {\n if (typeof value !== \"string\") return false;\n if (!options.allowEmpty && value.length === 0) return false;\n return value.length <= (options.maxLength ?? MAX_RESPONSE_STRING_LENGTH);\n}\n\nfunction validateEndpoint(value: unknown): string {\n if (\n typeof value !== \"string\" ||\n value.length === 0 ||\n value.trim() !== value\n ) {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n let endpoint: URL;\n try {\n endpoint = new URL(value);\n } catch {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n if (\n (endpoint.protocol !== \"http:\" && endpoint.protocol !== \"https:\") ||\n endpoint.username !== \"\" ||\n endpoint.password !== \"\" ||\n endpoint.search !== \"\" ||\n endpoint.hash !== \"\"\n ) {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n return value;\n}\n\nfunction validateTimeout(value: unknown): number {\n if (\n value === undefined ||\n (typeof value === \"number\" &&\n Number.isFinite(value) &&\n Number.isInteger(value) &&\n value > 0)\n ) {\n return value ?? DEFAULT_TIMEOUT_MS;\n }\n\n throw new DeploymentProviderError(\"invalid-configuration\");\n}\n\nfunction parseDeploymentUrl(value: unknown): string | null {\n if (!isBoundedString(value, { maxLength: MAX_RESPONSE_URL_LENGTH })) {\n return null;\n }\n\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return null;\n }\n\n if (url.protocol !== \"https:\" || url.username !== \"\" || url.password !== \"\") {\n return null;\n }\n\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction isDeploymentServiceSuccessResponse(\n value: unknown,\n): value is DeploymentServiceSuccessResponse {\n if (!isRecord(value)) return false;\n\n return (\n isBoundedString(value.repository) &&\n isBoundedString(value.ref) &&\n isBoundedString(value.commitSha) &&\n Number.isInteger(value.sourceFileCount) &&\n Number(value.sourceFileCount) >= 0 &&\n isBoundedString(value.project) &&\n value.deployed === true &&\n isBoundedString(value.deploymentId) &&\n isBoundedString(value.url, { maxLength: MAX_RESPONSE_URL_LENGTH }) &&\n isDeploymentReadyState(value.readyState)\n );\n}\n\nfunction isDeploymentServiceFailureResponse(\n value: unknown,\n): value is DeploymentServiceFailureResponse {\n if (!isRecord(value)) return false;\n\n const optionalStringFields = [\"ref\", \"commitSha\", \"project\", \"stage\"];\n return (\n isBoundedString(value.repository) &&\n value.deployed === false &&\n Number.isInteger(value.statusCode) &&\n Number(value.statusCode) >= 0 &&\n isBoundedString(value.details) &&\n optionalStringFields.every(\n (field) => !Object.hasOwn(value, field) || isBoundedString(value[field]),\n )\n );\n}\n\nasync function readResponse(response: Response): Promise<unknown> {\n try {\n return await response.json();\n } catch {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n}\n\nfunction parseSuccessResponse(value: unknown): DeploymentProviderResult {\n if (!isDeploymentServiceSuccessResponse(value)) {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n\n const url = parseDeploymentUrl(value.url);\n if (url === null) {\n throw new DeploymentProviderError(\"invalid-url\");\n }\n\n return {\n url,\n deploymentId: value.deploymentId,\n readyState: value.readyState,\n };\n}\n\nfunction parseFailureResponse(value: unknown): never {\n if (!isDeploymentServiceFailureResponse(value)) {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n\n throw new DeploymentProviderError(\"service-failure\");\n}\n\nexport function createDeploymentProvider({\n endpoint: configuredEndpoint,\n timeoutMs: configuredTimeout,\n}: CreateDeploymentProviderOptions): DeploymentProvider {\n const endpoint = validateEndpoint(configuredEndpoint);\n const timeoutMs = validateTimeout(configuredTimeout);\n const fetchImplementation = globalThis.fetch;\n\n if (typeof fetchImplementation !== \"function\") {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n return {\n async deploy(input) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetchImplementation(endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n applicationId: input.applicationId,\n login: input.login,\n }),\n cache: \"no-store\",\n signal: controller.signal,\n });\n } catch {\n if (controller.signal.aborted) {\n throw new DeploymentProviderError(\"timeout\");\n }\n throw new DeploymentProviderError(\"service-failure\");\n } finally {\n clearTimeout(timeout);\n }\n\n const payload = await readResponse(response);\n if (response.status === 201) {\n return parseSuccessResponse(payload);\n }\n\n return parseFailureResponse(payload);\n },\n };\n}\n"]}
1
+ {"version":3,"file":"deployment-provider.d.ts","sourceRoot":"","sources":["../src/deployment-provider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAQlE;;;GAGG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,oBAAoB,CAAC;CAClC,CAAC;AAEF,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,KAAK,EAAE;QACZ,aAAa,EAAE,MAAM,CAAC;QACtB,KAAK,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAC;CACvC;AAED,MAAM,MAAM,+BAA+B,GAAG;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GACnC,SAAS,GACT,iBAAiB,GACjB,mBAAmB,GACnB,kBAAkB,GAClB,aAAa,GACb,uBAAuB,CAAC;AAE5B;;;GAGG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,QAAQ,CAAC,IAAI,EAAE,2BAA2B,CAAC;IAE3C,YAAY,IAAI,EAAE,2BAA2B,EAI5C;CACF;AA+KD,wBAAgB,wBAAwB,CAAC,EACvC,QAAQ,EAAE,kBAAkB,EAC5B,SAAS,EAAE,iBAAiB,GAC7B,EAAE,+BAA+B,GAAG,kBAAkB,CA2CtD","sourcesContent":["import type { DeploymentReadyState } from \"@ebay-dt/bonfire-core\";\nimport { isDeploymentReadyState } from \"@ebay-dt/bonfire-core\";\nimport { isRecord } from \"./internal/utils.js\";\n\nconst DEFAULT_TIMEOUT_MS = 120_000;\nconst MAX_RESPONSE_STRING_LENGTH = 2_000;\nconst MAX_RESPONSE_URL_LENGTH = 8_192;\n\n/**\n * The normalized result returned by a deployment provider. `url` is the\n * deployment-root URL; the lifecycle service adds the active prototype route.\n */\nexport type DeploymentProviderResult = {\n url: string;\n deploymentId: string;\n commitSha: string;\n readyState: DeploymentReadyState;\n};\n\n/** Server-side transport boundary for creating an application deployment. */\nexport interface DeploymentProvider {\n deploy(input: {\n applicationId: string;\n login: string;\n }): Promise<DeploymentProviderResult>;\n}\n\nexport type CreateDeploymentProviderOptions = {\n endpoint: string;\n timeoutMs?: number;\n};\n\nexport type DeploymentProviderErrorKind =\n | \"timeout\"\n | \"service-failure\"\n | \"transport-failure\"\n | \"invalid-response\"\n | \"invalid-url\"\n | \"invalid-configuration\";\n\n/**\n * Safe provider failure classification. It intentionally carries no response\n * body, endpoint credentials, or unbounded service diagnostics.\n */\nexport class DeploymentProviderError extends Error {\n readonly kind: DeploymentProviderErrorKind;\n\n constructor(kind: DeploymentProviderErrorKind) {\n super(\"Deployment provider request failed\");\n this.name = \"DeploymentProviderError\";\n this.kind = kind;\n }\n}\n\n/**\n * Internal DT deployment success wire shape. The service diagnostics are\n * validated at the transport boundary but are not exposed as client data.\n */\ntype DeploymentServiceSuccessResponse = {\n repository: string;\n ref: string;\n commitSha: string;\n sourceFileCount: number;\n project: string;\n deployed: true;\n deploymentId: string;\n url: string;\n readyState: DeploymentReadyState;\n};\n\n/** Internal DT deployment failure wire shape. */\ntype DeploymentServiceFailureResponse = {\n repository: string;\n ref?: string;\n commitSha?: string;\n project?: string;\n stage?: string;\n deployed: false;\n statusCode: number;\n details: string;\n};\n\ntype DeploymentServiceResponse =\n DeploymentServiceSuccessResponse | DeploymentServiceFailureResponse;\n\nfunction isBoundedString(\n value: unknown,\n options: { allowEmpty?: boolean; maxLength?: number } = {},\n): value is string {\n if (typeof value !== \"string\") return false;\n if (!options.allowEmpty && value.length === 0) return false;\n return value.length <= (options.maxLength ?? MAX_RESPONSE_STRING_LENGTH);\n}\n\nfunction validateEndpoint(value: unknown): string {\n if (\n typeof value !== \"string\" ||\n value.length === 0 ||\n value.trim() !== value\n ) {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n let endpoint: URL;\n try {\n endpoint = new URL(value);\n } catch {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n if (\n (endpoint.protocol !== \"http:\" && endpoint.protocol !== \"https:\") ||\n endpoint.username !== \"\" ||\n endpoint.password !== \"\" ||\n endpoint.search !== \"\" ||\n endpoint.hash !== \"\"\n ) {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n return value;\n}\n\nfunction validateTimeout(value: unknown): number {\n if (\n value === undefined ||\n (typeof value === \"number\" &&\n Number.isFinite(value) &&\n Number.isInteger(value) &&\n value > 0)\n ) {\n return value ?? DEFAULT_TIMEOUT_MS;\n }\n\n throw new DeploymentProviderError(\"invalid-configuration\");\n}\n\nfunction parseDeploymentUrl(value: unknown): string | null {\n if (!isBoundedString(value, { maxLength: MAX_RESPONSE_URL_LENGTH })) {\n return null;\n }\n\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return null;\n }\n\n if (url.protocol !== \"https:\" || url.username !== \"\" || url.password !== \"\") {\n return null;\n }\n\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction isDeploymentServiceSuccessResponse(\n value: unknown,\n): value is DeploymentServiceSuccessResponse {\n if (!isRecord(value)) return false;\n\n return (\n isBoundedString(value.repository) &&\n isBoundedString(value.ref) &&\n isBoundedString(value.commitSha) &&\n Number.isInteger(value.sourceFileCount) &&\n Number(value.sourceFileCount) >= 0 &&\n isBoundedString(value.project) &&\n value.deployed === true &&\n isBoundedString(value.deploymentId) &&\n isBoundedString(value.url, { maxLength: MAX_RESPONSE_URL_LENGTH }) &&\n isDeploymentReadyState(value.readyState)\n );\n}\n\nfunction isDeploymentServiceFailureResponse(\n value: unknown,\n): value is DeploymentServiceFailureResponse {\n if (!isRecord(value)) return false;\n\n const optionalStringFields = [\"ref\", \"commitSha\", \"project\", \"stage\"];\n return (\n isBoundedString(value.repository) &&\n value.deployed === false &&\n Number.isInteger(value.statusCode) &&\n Number(value.statusCode) >= 0 &&\n isBoundedString(value.details) &&\n optionalStringFields.every(\n (field) => !Object.hasOwn(value, field) || isBoundedString(value[field]),\n )\n );\n}\n\nasync function readResponse(response: Response): Promise<unknown> {\n try {\n return await response.json();\n } catch {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n}\n\nfunction parseSuccessResponse(value: unknown): DeploymentProviderResult {\n if (!isDeploymentServiceSuccessResponse(value)) {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n\n const url = parseDeploymentUrl(value.url);\n if (url === null) {\n throw new DeploymentProviderError(\"invalid-url\");\n }\n\n return {\n url,\n deploymentId: value.deploymentId,\n commitSha: value.commitSha,\n readyState: value.readyState,\n };\n}\n\nfunction parseFailureResponse(value: unknown): never {\n if (!isDeploymentServiceFailureResponse(value)) {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n\n throw new DeploymentProviderError(\"service-failure\");\n}\n\nexport function createDeploymentProvider({\n endpoint: configuredEndpoint,\n timeoutMs: configuredTimeout,\n}: CreateDeploymentProviderOptions): DeploymentProvider {\n const endpoint = validateEndpoint(configuredEndpoint);\n const timeoutMs = validateTimeout(configuredTimeout);\n const fetchImplementation = globalThis.fetch;\n\n if (typeof fetchImplementation !== \"function\") {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n return {\n async deploy(input) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetchImplementation(endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n applicationId: input.applicationId,\n login: input.login,\n }),\n cache: \"no-store\",\n signal: controller.signal,\n });\n } catch {\n if (controller.signal.aborted) {\n throw new DeploymentProviderError(\"timeout\");\n }\n throw new DeploymentProviderError(\"transport-failure\");\n } finally {\n clearTimeout(timeout);\n }\n\n const payload = await readResponse(response);\n if (response.status === 201) {\n return parseSuccessResponse(payload);\n }\n\n return parseFailureResponse(payload);\n },\n };\n}\n"]}
@@ -114,6 +114,7 @@ function parseSuccessResponse(value) {
114
114
  return {
115
115
  url,
116
116
  deploymentId: value.deploymentId,
117
+ commitSha: value.commitSha,
117
118
  readyState: value.readyState,
118
119
  };
119
120
  }
@@ -151,7 +152,7 @@ export function createDeploymentProvider({ endpoint: configuredEndpoint, timeout
151
152
  if (controller.signal.aborted) {
152
153
  throw new DeploymentProviderError("timeout");
153
154
  }
154
- throw new DeploymentProviderError("service-failure");
155
+ throw new DeploymentProviderError("transport-failure");
155
156
  }
156
157
  finally {
157
158
  clearTimeout(timeout);
@@ -1 +1 @@
1
- {"version":3,"file":"deployment-provider.js","sourceRoot":"","sources":["../src/deployment-provider.ts"],"names":[],"mappings":"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","sourcesContent":["import type { DeploymentReadyState } from \"@ebay-dt/bonfire-core\";\nimport { isDeploymentReadyState } from \"@ebay-dt/bonfire-core\";\nimport { isRecord } from \"./internal/utils.js\";\n\nconst DEFAULT_TIMEOUT_MS = 120_000;\nconst MAX_RESPONSE_STRING_LENGTH = 2_000;\nconst MAX_RESPONSE_URL_LENGTH = 8_192;\n\n/**\n * The normalized result returned by a deployment provider. `url` is the\n * deployment-root URL; the lifecycle service adds the active prototype route.\n */\nexport type DeploymentProviderResult = {\n url: string;\n deploymentId: string;\n readyState: DeploymentReadyState;\n};\n\n/** Server-side transport boundary for creating an application deployment. */\nexport interface DeploymentProvider {\n deploy(input: {\n applicationId: string;\n login: string;\n }): Promise<DeploymentProviderResult>;\n}\n\nexport type CreateDeploymentProviderOptions = {\n endpoint: string;\n timeoutMs?: number;\n};\n\nexport type DeploymentProviderErrorKind =\n | \"timeout\"\n | \"service-failure\"\n | \"invalid-response\"\n | \"invalid-url\"\n | \"invalid-configuration\";\n\n/**\n * Safe provider failure classification. It intentionally carries no response\n * body, endpoint credentials, or unbounded service diagnostics.\n */\nexport class DeploymentProviderError extends Error {\n readonly kind: DeploymentProviderErrorKind;\n\n constructor(kind: DeploymentProviderErrorKind) {\n super(\"Deployment provider request failed\");\n this.name = \"DeploymentProviderError\";\n this.kind = kind;\n }\n}\n\n/**\n * Internal DT deployment success wire shape. The service diagnostics are\n * validated at the transport boundary but are not exposed as client data.\n */\ntype DeploymentServiceSuccessResponse = {\n repository: string;\n ref: string;\n commitSha: string;\n sourceFileCount: number;\n project: string;\n deployed: true;\n deploymentId: string;\n url: string;\n readyState: DeploymentReadyState;\n};\n\n/** Internal DT deployment failure wire shape. */\ntype DeploymentServiceFailureResponse = {\n repository: string;\n ref?: string;\n commitSha?: string;\n project?: string;\n stage?: string;\n deployed: false;\n statusCode: number;\n details: string;\n};\n\ntype DeploymentServiceResponse =\n DeploymentServiceSuccessResponse | DeploymentServiceFailureResponse;\n\nfunction isBoundedString(\n value: unknown,\n options: { allowEmpty?: boolean; maxLength?: number } = {},\n): value is string {\n if (typeof value !== \"string\") return false;\n if (!options.allowEmpty && value.length === 0) return false;\n return value.length <= (options.maxLength ?? MAX_RESPONSE_STRING_LENGTH);\n}\n\nfunction validateEndpoint(value: unknown): string {\n if (\n typeof value !== \"string\" ||\n value.length === 0 ||\n value.trim() !== value\n ) {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n let endpoint: URL;\n try {\n endpoint = new URL(value);\n } catch {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n if (\n (endpoint.protocol !== \"http:\" && endpoint.protocol !== \"https:\") ||\n endpoint.username !== \"\" ||\n endpoint.password !== \"\" ||\n endpoint.search !== \"\" ||\n endpoint.hash !== \"\"\n ) {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n return value;\n}\n\nfunction validateTimeout(value: unknown): number {\n if (\n value === undefined ||\n (typeof value === \"number\" &&\n Number.isFinite(value) &&\n Number.isInteger(value) &&\n value > 0)\n ) {\n return value ?? DEFAULT_TIMEOUT_MS;\n }\n\n throw new DeploymentProviderError(\"invalid-configuration\");\n}\n\nfunction parseDeploymentUrl(value: unknown): string | null {\n if (!isBoundedString(value, { maxLength: MAX_RESPONSE_URL_LENGTH })) {\n return null;\n }\n\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return null;\n }\n\n if (url.protocol !== \"https:\" || url.username !== \"\" || url.password !== \"\") {\n return null;\n }\n\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction isDeploymentServiceSuccessResponse(\n value: unknown,\n): value is DeploymentServiceSuccessResponse {\n if (!isRecord(value)) return false;\n\n return (\n isBoundedString(value.repository) &&\n isBoundedString(value.ref) &&\n isBoundedString(value.commitSha) &&\n Number.isInteger(value.sourceFileCount) &&\n Number(value.sourceFileCount) >= 0 &&\n isBoundedString(value.project) &&\n value.deployed === true &&\n isBoundedString(value.deploymentId) &&\n isBoundedString(value.url, { maxLength: MAX_RESPONSE_URL_LENGTH }) &&\n isDeploymentReadyState(value.readyState)\n );\n}\n\nfunction isDeploymentServiceFailureResponse(\n value: unknown,\n): value is DeploymentServiceFailureResponse {\n if (!isRecord(value)) return false;\n\n const optionalStringFields = [\"ref\", \"commitSha\", \"project\", \"stage\"];\n return (\n isBoundedString(value.repository) &&\n value.deployed === false &&\n Number.isInteger(value.statusCode) &&\n Number(value.statusCode) >= 0 &&\n isBoundedString(value.details) &&\n optionalStringFields.every(\n (field) => !Object.hasOwn(value, field) || isBoundedString(value[field]),\n )\n );\n}\n\nasync function readResponse(response: Response): Promise<unknown> {\n try {\n return await response.json();\n } catch {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n}\n\nfunction parseSuccessResponse(value: unknown): DeploymentProviderResult {\n if (!isDeploymentServiceSuccessResponse(value)) {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n\n const url = parseDeploymentUrl(value.url);\n if (url === null) {\n throw new DeploymentProviderError(\"invalid-url\");\n }\n\n return {\n url,\n deploymentId: value.deploymentId,\n readyState: value.readyState,\n };\n}\n\nfunction parseFailureResponse(value: unknown): never {\n if (!isDeploymentServiceFailureResponse(value)) {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n\n throw new DeploymentProviderError(\"service-failure\");\n}\n\nexport function createDeploymentProvider({\n endpoint: configuredEndpoint,\n timeoutMs: configuredTimeout,\n}: CreateDeploymentProviderOptions): DeploymentProvider {\n const endpoint = validateEndpoint(configuredEndpoint);\n const timeoutMs = validateTimeout(configuredTimeout);\n const fetchImplementation = globalThis.fetch;\n\n if (typeof fetchImplementation !== \"function\") {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n return {\n async deploy(input) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetchImplementation(endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n applicationId: input.applicationId,\n login: input.login,\n }),\n cache: \"no-store\",\n signal: controller.signal,\n });\n } catch {\n if (controller.signal.aborted) {\n throw new DeploymentProviderError(\"timeout\");\n }\n throw new DeploymentProviderError(\"service-failure\");\n } finally {\n clearTimeout(timeout);\n }\n\n const payload = await readResponse(response);\n if (response.status === 201) {\n return parseSuccessResponse(payload);\n }\n\n return parseFailureResponse(payload);\n },\n };\n}\n"]}
1
+ {"version":3,"file":"deployment-provider.js","sourceRoot":"","sources":["../src/deployment-provider.ts"],"names":[],"mappings":"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","sourcesContent":["import type { DeploymentReadyState } from \"@ebay-dt/bonfire-core\";\nimport { isDeploymentReadyState } from \"@ebay-dt/bonfire-core\";\nimport { isRecord } from \"./internal/utils.js\";\n\nconst DEFAULT_TIMEOUT_MS = 120_000;\nconst MAX_RESPONSE_STRING_LENGTH = 2_000;\nconst MAX_RESPONSE_URL_LENGTH = 8_192;\n\n/**\n * The normalized result returned by a deployment provider. `url` is the\n * deployment-root URL; the lifecycle service adds the active prototype route.\n */\nexport type DeploymentProviderResult = {\n url: string;\n deploymentId: string;\n commitSha: string;\n readyState: DeploymentReadyState;\n};\n\n/** Server-side transport boundary for creating an application deployment. */\nexport interface DeploymentProvider {\n deploy(input: {\n applicationId: string;\n login: string;\n }): Promise<DeploymentProviderResult>;\n}\n\nexport type CreateDeploymentProviderOptions = {\n endpoint: string;\n timeoutMs?: number;\n};\n\nexport type DeploymentProviderErrorKind =\n | \"timeout\"\n | \"service-failure\"\n | \"transport-failure\"\n | \"invalid-response\"\n | \"invalid-url\"\n | \"invalid-configuration\";\n\n/**\n * Safe provider failure classification. It intentionally carries no response\n * body, endpoint credentials, or unbounded service diagnostics.\n */\nexport class DeploymentProviderError extends Error {\n readonly kind: DeploymentProviderErrorKind;\n\n constructor(kind: DeploymentProviderErrorKind) {\n super(\"Deployment provider request failed\");\n this.name = \"DeploymentProviderError\";\n this.kind = kind;\n }\n}\n\n/**\n * Internal DT deployment success wire shape. The service diagnostics are\n * validated at the transport boundary but are not exposed as client data.\n */\ntype DeploymentServiceSuccessResponse = {\n repository: string;\n ref: string;\n commitSha: string;\n sourceFileCount: number;\n project: string;\n deployed: true;\n deploymentId: string;\n url: string;\n readyState: DeploymentReadyState;\n};\n\n/** Internal DT deployment failure wire shape. */\ntype DeploymentServiceFailureResponse = {\n repository: string;\n ref?: string;\n commitSha?: string;\n project?: string;\n stage?: string;\n deployed: false;\n statusCode: number;\n details: string;\n};\n\ntype DeploymentServiceResponse =\n DeploymentServiceSuccessResponse | DeploymentServiceFailureResponse;\n\nfunction isBoundedString(\n value: unknown,\n options: { allowEmpty?: boolean; maxLength?: number } = {},\n): value is string {\n if (typeof value !== \"string\") return false;\n if (!options.allowEmpty && value.length === 0) return false;\n return value.length <= (options.maxLength ?? MAX_RESPONSE_STRING_LENGTH);\n}\n\nfunction validateEndpoint(value: unknown): string {\n if (\n typeof value !== \"string\" ||\n value.length === 0 ||\n value.trim() !== value\n ) {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n let endpoint: URL;\n try {\n endpoint = new URL(value);\n } catch {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n if (\n (endpoint.protocol !== \"http:\" && endpoint.protocol !== \"https:\") ||\n endpoint.username !== \"\" ||\n endpoint.password !== \"\" ||\n endpoint.search !== \"\" ||\n endpoint.hash !== \"\"\n ) {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n return value;\n}\n\nfunction validateTimeout(value: unknown): number {\n if (\n value === undefined ||\n (typeof value === \"number\" &&\n Number.isFinite(value) &&\n Number.isInteger(value) &&\n value > 0)\n ) {\n return value ?? DEFAULT_TIMEOUT_MS;\n }\n\n throw new DeploymentProviderError(\"invalid-configuration\");\n}\n\nfunction parseDeploymentUrl(value: unknown): string | null {\n if (!isBoundedString(value, { maxLength: MAX_RESPONSE_URL_LENGTH })) {\n return null;\n }\n\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return null;\n }\n\n if (url.protocol !== \"https:\" || url.username !== \"\" || url.password !== \"\") {\n return null;\n }\n\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction isDeploymentServiceSuccessResponse(\n value: unknown,\n): value is DeploymentServiceSuccessResponse {\n if (!isRecord(value)) return false;\n\n return (\n isBoundedString(value.repository) &&\n isBoundedString(value.ref) &&\n isBoundedString(value.commitSha) &&\n Number.isInteger(value.sourceFileCount) &&\n Number(value.sourceFileCount) >= 0 &&\n isBoundedString(value.project) &&\n value.deployed === true &&\n isBoundedString(value.deploymentId) &&\n isBoundedString(value.url, { maxLength: MAX_RESPONSE_URL_LENGTH }) &&\n isDeploymentReadyState(value.readyState)\n );\n}\n\nfunction isDeploymentServiceFailureResponse(\n value: unknown,\n): value is DeploymentServiceFailureResponse {\n if (!isRecord(value)) return false;\n\n const optionalStringFields = [\"ref\", \"commitSha\", \"project\", \"stage\"];\n return (\n isBoundedString(value.repository) &&\n value.deployed === false &&\n Number.isInteger(value.statusCode) &&\n Number(value.statusCode) >= 0 &&\n isBoundedString(value.details) &&\n optionalStringFields.every(\n (field) => !Object.hasOwn(value, field) || isBoundedString(value[field]),\n )\n );\n}\n\nasync function readResponse(response: Response): Promise<unknown> {\n try {\n return await response.json();\n } catch {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n}\n\nfunction parseSuccessResponse(value: unknown): DeploymentProviderResult {\n if (!isDeploymentServiceSuccessResponse(value)) {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n\n const url = parseDeploymentUrl(value.url);\n if (url === null) {\n throw new DeploymentProviderError(\"invalid-url\");\n }\n\n return {\n url,\n deploymentId: value.deploymentId,\n commitSha: value.commitSha,\n readyState: value.readyState,\n };\n}\n\nfunction parseFailureResponse(value: unknown): never {\n if (!isDeploymentServiceFailureResponse(value)) {\n throw new DeploymentProviderError(\"invalid-response\");\n }\n\n throw new DeploymentProviderError(\"service-failure\");\n}\n\nexport function createDeploymentProvider({\n endpoint: configuredEndpoint,\n timeoutMs: configuredTimeout,\n}: CreateDeploymentProviderOptions): DeploymentProvider {\n const endpoint = validateEndpoint(configuredEndpoint);\n const timeoutMs = validateTimeout(configuredTimeout);\n const fetchImplementation = globalThis.fetch;\n\n if (typeof fetchImplementation !== \"function\") {\n throw new DeploymentProviderError(\"invalid-configuration\");\n }\n\n return {\n async deploy(input) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetchImplementation(endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n applicationId: input.applicationId,\n login: input.login,\n }),\n cache: \"no-store\",\n signal: controller.signal,\n });\n } catch {\n if (controller.signal.aborted) {\n throw new DeploymentProviderError(\"timeout\");\n }\n throw new DeploymentProviderError(\"transport-failure\");\n } finally {\n clearTimeout(timeout);\n }\n\n const payload = await readResponse(response);\n if (response.status === 201) {\n return parseSuccessResponse(payload);\n }\n\n return parseFailureResponse(payload);\n },\n };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"duplicate.d.ts","sourceRoot":"","sources":["../src/duplicate.ts"],"names":[],"mappings":"AAYA,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,aAAa,EACnB,MAAM,uBAAuB,CAAC;AAO/B,OAAO,EAEL,KAAK,sBAAsB,EAC5B,MAAM,iCAAiC,CAAC;AACzC,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,sBAAsB,CAAC;AAM1E,KAAK,aAAa,GAAG,CACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,UAAU,GAAG,MAAM,EAC5B,OAAO,EAAE;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,KACpB,OAAO,CAAC,IAAI,CAAC,CAAC;AAEnB,MAAM,MAAM,yBAAyB,GAAG;IACtC,KAAK,EAAE,IAAI,CACT,6BAA6B,EAC7B,gBAAgB,GAAG,eAAe,CACnC,CAAC;IACF,MAAM,EAAE,YAAY,CAAC;IACrB,WAAW,EAAE,YAAY,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,SAAS,CAAC,EAAE,aAAa,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GACrC,kBAAkB,GAAG,gBAAgB,GAAG,WAAW,GAAG,YAAY,CAAC;AAErE,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,QAAQ,CAAC,IAAI,EAAE,2BAA2B,CAAC;IAE3C,YAAY,IAAI,EAAE,2BAA2B,EAI5C;CACF;AAkSD,wBAAsB,kBAAkB,CAAC,EACvC,KAAK,EACL,MAAM,EACN,WAAW,EAAE,cAAc,EAC3B,KAAK,EACL,WAAW,EACX,UAAU,EACV,aAAa,EACb,KAAwB,EACxB,SAA0B,GAC3B,EAAE,yBAAyB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CA0H7D","sourcesContent":["import { constants } from \"node:fs\";\nimport {\n lstat,\n mkdir,\n open,\n readdir,\n rm,\n rmdir,\n writeFile as writeFileAsync,\n} from \"node:fs/promises\";\nimport { dirname, join, relative } from \"node:path\";\nimport type { Dirent } from \"node:fs\";\nimport {\n definePrototypeRef,\n parsePrototypeMetadata,\n type PrototypeMetadata,\n type PrototypeRef,\n type ThemeRegistry,\n} from \"@ebay-dt/bonfire-core\";\nimport {\n buildCssModulesDeclaration,\n buildMetadataJson,\n buildTsConfig,\n COPYABLE_IMPLEMENTATION_FILES,\n} from \"./internal/prototype-templates.js\";\nimport {\n captureOwnedDestination,\n type OwnedDestinationResult,\n} from \"./internal/owned-destination.js\";\nimport type { ResolvedPrototypeCatalogPaths } from \"./prototype-paths.js\";\nimport { isFileNotFound } from \"./internal/utils.js\";\n\nconst FILE_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);\nconst BLOCKED_COMPONENT_NAMES = new Set([\".next\", \"node_modules\"]);\n\ntype SafeWriteFile = (\n path: string,\n content: Uint8Array | string,\n options: { flag: \"wx\" },\n) => Promise<void>;\n\nexport type DuplicatePrototypeOptions = {\n paths: Pick<\n ResolvedPrototypeCatalogPaths,\n \"repositoryRoot\" | \"prototypeRoot\"\n >;\n source: PrototypeRef;\n destination: PrototypeRef;\n title: string;\n description: string | null;\n authorName: string | null;\n themeRegistry?: ThemeRegistry;\n clock?: () => Date;\n writeFile?: SafeWriteFile;\n};\n\nexport type PrototypeDuplicateErrorKind =\n \"source-not-found\" | \"invalid-source\" | \"collision\" | \"filesystem\";\n\nexport class PrototypeDuplicateError extends Error {\n readonly kind: PrototypeDuplicateErrorKind;\n\n constructor(kind: PrototypeDuplicateErrorKind) {\n super(`Prototype duplication failed: ${kind}`);\n this.name = \"PrototypeDuplicateError\";\n this.kind = kind;\n }\n}\n\ntype OwnedPath = {\n path: string;\n device: number;\n inode: number;\n};\n\nfunction sourcePathFor(root: string, ref: PrototypeRef) {\n return join(root, ref.login, ref.slug);\n}\n\nasync function ownedPath(path: string): Promise<OwnedPath> {\n const stats = await lstat(path);\n return { path, device: stats.dev, inode: stats.ino };\n}\n\nasync function assertDirectory(\n path: string,\n kind: \"source-not-found\" | \"invalid-source\" | \"filesystem\",\n) {\n try {\n const stats = await lstat(path);\n if (stats.isSymbolicLink() || !stats.isDirectory()) {\n throw new PrototypeDuplicateError(kind);\n }\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (isFileNotFound(error)) throw new PrototypeDuplicateError(kind);\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function readNoFollow(\n path: string,\n kind: \"invalid-source\" | \"filesystem\",\n) {\n try {\n const stats = await lstat(path);\n if (stats.isSymbolicLink() || !stats.isFile()) {\n throw new PrototypeDuplicateError(kind);\n }\n const file = await open(path, FILE_OPEN_FLAGS);\n try {\n return await file.readFile();\n } finally {\n await file.close();\n }\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (isFileNotFound(error)) return null;\n throw new PrototypeDuplicateError(kind);\n }\n}\n\nasync function readRequiredSourceFile(path: string) {\n const content = await readNoFollow(path, \"invalid-source\");\n if (content === null) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n return content;\n}\n\nasync function listComponentFiles(\n root: string,\n current: string,\n): Promise<string[]> {\n let entries: Dirent[];\n try {\n entries = await readdir(current, { withFileTypes: true });\n } catch {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n\n const files: string[] = [];\n for (const entry of entries.sort((left, right) =>\n left.name.localeCompare(right.name),\n )) {\n const path = join(current, entry.name);\n let stats;\n try {\n stats = await lstat(path);\n } catch {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n if (stats.isSymbolicLink()) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n if (\n entry.name === \".gitkeep\" ||\n BLOCKED_COMPONENT_NAMES.has(entry.name) ||\n entry.name.startsWith(\".env\")\n ) {\n continue;\n }\n if (stats.isDirectory()) {\n files.push(...(await listComponentFiles(root, path)));\n continue;\n }\n if (!stats.isFile()) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n files.push(relative(root, path));\n }\n return files;\n}\n\nasync function inspectSource(\n sourcePath: string,\n themeRegistry?: ThemeRegistry,\n) {\n await assertDirectory(sourcePath, \"source-not-found\");\n\n try {\n await lstat(join(sourcePath, \"page.module.css\"));\n throw new PrototypeDuplicateError(\"invalid-source\");\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (!isFileNotFound(error)) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n }\n\n const metadataBytes = await readRequiredSourceFile(\n join(sourcePath, \"metadata.json\"),\n );\n let metadata: PrototypeMetadata;\n try {\n metadata = parsePrototypeMetadata(\n JSON.parse(metadataBytes.toString(\"utf8\")),\n { themeRegistry },\n );\n } catch {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n\n const implementationFiles = new Map<string, Uint8Array>();\n for (const name of COPYABLE_IMPLEMENTATION_FILES) {\n implementationFiles.set(\n name,\n await readRequiredSourceFile(join(sourcePath, name)),\n );\n }\n\n const componentsPath = join(sourcePath, \"components\");\n let componentFiles: string[] = [];\n try {\n const stats = await lstat(componentsPath);\n if (stats.isSymbolicLink() || !stats.isDirectory()) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n componentFiles = await listComponentFiles(componentsPath, componentsPath);\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (!isFileNotFound(error)) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n }\n\n return { metadata, implementationFiles, componentFiles };\n}\n\nasync function reserveDestination(\n path: string,\n paths: Pick<\n ResolvedPrototypeCatalogPaths,\n \"repositoryRoot\" | \"prototypeRoot\"\n >,\n) {\n try {\n await mkdir(path);\n } catch (error) {\n if (\n error !== null &&\n typeof error === \"object\" &&\n \"code\" in error &&\n error.code === \"EEXIST\"\n ) {\n throw new PrototypeDuplicateError(\"collision\");\n }\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n try {\n return await captureOwnedDestination({\n path,\n repositoryRoot: paths.repositoryRoot,\n prototypeRoot: paths.prototypeRoot,\n });\n } catch {\n // The newly created path is retained because ownership was not proven.\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function ensureDirectory(path: string) {\n try {\n await mkdir(path, { recursive: true });\n await assertDirectory(path, \"filesystem\");\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function createDirectory(path: string) {\n try {\n await mkdir(path);\n await assertDirectory(path, \"filesystem\");\n return await ownedPath(path);\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function createNestedDirectories(path: string, existingRoot: string) {\n const missing: string[] = [];\n let current = path;\n while (current !== existingRoot) {\n try {\n const stats = await lstat(current);\n if (stats.isSymbolicLink() || !stats.isDirectory()) {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n break;\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (!isFileNotFound(error)) {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n missing.push(current);\n const parent = dirname(current);\n if (parent === current) throw new PrototypeDuplicateError(\"filesystem\");\n current = parent;\n }\n }\n\n const owned: OwnedPath[] = [];\n for (const directory of missing.reverse()) {\n owned.push(await createDirectory(directory));\n }\n return owned;\n}\n\nasync function writeExclusive(\n path: string,\n content: Uint8Array | string,\n writeFile: SafeWriteFile,\n) {\n try {\n await writeFile(path, content, { flag: \"wx\" });\n return await ownedPath(path);\n } catch {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function removeOwnedPaths(\n files: readonly OwnedPath[],\n directories: readonly OwnedPath[],\n) {\n try {\n for (const owned of [...files].reverse()) {\n const current = await lstat(owned.path).catch(() => null);\n if (\n current !== null &&\n !current.isSymbolicLink() &&\n current.dev === owned.device &&\n current.ino === owned.inode\n ) {\n await rm(owned.path, { force: true });\n }\n }\n for (const owned of [...directories].reverse()) {\n const current = await lstat(owned.path).catch(() => null);\n if (\n current !== null &&\n current.isDirectory() &&\n !current.isSymbolicLink() &&\n current.dev === owned.device &&\n current.ino === owned.inode\n ) {\n await rmdir(owned.path);\n }\n }\n } catch {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nexport async function duplicatePrototype({\n paths,\n source,\n destination: destinationRef,\n title,\n description,\n authorName,\n themeRegistry,\n clock = () => new Date(),\n writeFile = writeFileAsync,\n}: DuplicatePrototypeOptions): Promise<OwnedDestinationResult> {\n const normalizedSource = definePrototypeRef(source);\n const normalizedDestination = definePrototypeRef(destinationRef);\n const sourcePath = sourcePathFor(paths.prototypeRoot, normalizedSource);\n const destination = sourcePathFor(paths.prototypeRoot, normalizedDestination);\n const sourceDetails = await inspectSource(sourcePath, themeRegistry);\n if (sourceDetails.metadata.authorLogin !== normalizedSource.login) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n\n const timestamp = clock().toISOString();\n const metadata = buildMetadataJson(\n title,\n description,\n normalizedDestination.login,\n authorName,\n timestamp,\n sourceDetails.metadata.theme,\n );\n parsePrototypeMetadata(JSON.parse(metadata), { themeRegistry });\n\n await ensureDirectory(paths.prototypeRoot);\n await ensureDirectory(dirname(destination));\n const ownedFiles: OwnedPath[] = [];\n const receipt = await reserveDestination(destination, paths);\n const ownedDirectories: OwnedPath[] = [receipt];\n\n try {\n ownedDirectories.push(\n await createDirectory(join(destination, \"components\")),\n );\n\n for (const name of COPYABLE_IMPLEMENTATION_FILES) {\n ownedFiles.push(\n await writeExclusive(\n join(destination, name),\n sourceDetails.implementationFiles.get(name)!,\n writeFile,\n ),\n );\n }\n\n if (sourceDetails.componentFiles.length === 0) {\n ownedFiles.push(\n await writeExclusive(\n join(destination, \"components/.gitkeep\"),\n \"\",\n writeFile,\n ),\n );\n } else {\n for (const file of sourceDetails.componentFiles) {\n const sourceContent = await readRequiredSourceFile(\n join(sourcePath, \"components\", file),\n );\n const target = join(destination, \"components\", file);\n ownedDirectories.push(\n ...(await createNestedDirectories(\n dirname(target),\n join(destination, \"components\"),\n )),\n );\n ownedFiles.push(await writeExclusive(target, sourceContent, writeFile));\n }\n }\n\n ownedFiles.push(\n await writeExclusive(\n join(destination, \"metadata.json\"),\n metadata,\n writeFile,\n ),\n );\n ownedFiles.push(\n await writeExclusive(\n join(destination, \"tsconfig.json\"),\n buildTsConfig(paths.repositoryRoot, destination),\n writeFile,\n ),\n );\n ownedFiles.push(\n await writeExclusive(\n join(destination, \"css-modules.d.ts\"),\n buildCssModulesDeclaration(),\n writeFile,\n ),\n );\n\n const writtenMetadata = await readNoFollow(\n join(destination, \"metadata.json\"),\n \"filesystem\",\n );\n if (writtenMetadata === null) {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n parsePrototypeMetadata(JSON.parse(writtenMetadata.toString(\"utf8\")), {\n themeRegistry,\n });\n const expectedFiles = [\n ...COPYABLE_IMPLEMENTATION_FILES,\n ...sourceDetails.componentFiles.map((file) => join(\"components\", file)),\n ...(sourceDetails.componentFiles.length === 0\n ? [\"components/.gitkeep\"]\n : []),\n \"metadata.json\",\n \"tsconfig.json\",\n \"css-modules.d.ts\",\n ];\n return {\n destination,\n receipt,\n expectedFiles: Object.freeze(expectedFiles),\n };\n } catch (error) {\n try {\n await removeOwnedPaths(ownedFiles, ownedDirectories);\n } catch {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n if (error instanceof PrototypeDuplicateError) throw error;\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n"]}
1
+ {"version":3,"file":"duplicate.d.ts","sourceRoot":"","sources":["../src/duplicate.ts"],"names":[],"mappings":"AAWA,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,aAAa,EACnB,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAEL,KAAK,sBAAsB,EAC5B,MAAM,iCAAiC,CAAC;AACzC,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,sBAAsB,CAAC;AAe1E,KAAK,aAAa,GAAG,CACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,UAAU,GAAG,MAAM,EAC5B,OAAO,EAAE;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,KACpB,OAAO,CAAC,IAAI,CAAC,CAAC;AAEnB,MAAM,MAAM,yBAAyB,GAAG;IACtC,KAAK,EAAE,IAAI,CACT,6BAA6B,EAC7B,gBAAgB,GAAG,eAAe,CACnC,CAAC;IACF,MAAM,EAAE,YAAY,CAAC;IACrB,WAAW,EAAE,YAAY,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,SAAS,CAAC,EAAE,aAAa,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GACrC,kBAAkB,GAAG,gBAAgB,GAAG,WAAW,GAAG,YAAY,CAAC;AAErE,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,QAAQ,CAAC,IAAI,EAAE,2BAA2B,CAAC;IAE3C,YAAY,IAAI,EAAE,2BAA2B,EAI5C;CACF;AA6UD,wBAAsB,kBAAkB,CAAC,EACvC,KAAK,EACL,MAAM,EACN,WAAW,EAAE,cAAc,EAC3B,KAAK,EACL,WAAW,EACX,UAAU,EACV,aAAa,EACb,KAAwB,EACxB,SAA0B,GAC3B,EAAE,yBAAyB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAqH7D","sourcesContent":["import { constants } from \"node:fs\";\nimport {\n lstat,\n mkdir,\n open,\n readdir,\n rm,\n rmdir,\n writeFile as writeFileAsync,\n} from \"node:fs/promises\";\nimport { dirname, join, relative, sep } from \"node:path\";\nimport {\n definePrototypeRef,\n parsePrototypeMetadata,\n type PrototypeMetadata,\n type PrototypeRef,\n type ThemeRegistry,\n} from \"@ebay-dt/bonfire-core\";\nimport {\n buildCssModulesDeclaration,\n buildMetadataJson,\n buildTsConfig,\n} from \"./internal/prototype-templates.js\";\nimport {\n captureOwnedDestination,\n type OwnedDestinationResult,\n} from \"./internal/owned-destination.js\";\nimport type { ResolvedPrototypeCatalogPaths } from \"./prototype-paths.js\";\nimport { isFileNotFound } from \"./internal/utils.js\";\nimport { isProtectedPrototypePath } from \"./lifecycle/prototype-path-policy.js\";\n\nconst FILE_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);\n// These are preparation context, rather than authored prototype content. The\n// credential/runtime exclusions are shared with lifecycle path operations.\nconst FRESH_CONTEXT_NAMES = new Set([\".claude\", \"AGENTS.md\", \"CLAUDE.md\"]);\n\nconst GENERATED_ROOT_FILES = new Set([\n \"metadata.json\",\n \"tsconfig.json\",\n \"css-modules.d.ts\",\n]);\n\ntype SafeWriteFile = (\n path: string,\n content: Uint8Array | string,\n options: { flag: \"wx\" },\n) => Promise<void>;\n\nexport type DuplicatePrototypeOptions = {\n paths: Pick<\n ResolvedPrototypeCatalogPaths,\n \"repositoryRoot\" | \"prototypeRoot\"\n >;\n source: PrototypeRef;\n destination: PrototypeRef;\n title: string;\n description: string | null;\n authorName: string | null;\n themeRegistry?: ThemeRegistry;\n clock?: () => Date;\n writeFile?: SafeWriteFile;\n};\n\nexport type PrototypeDuplicateErrorKind =\n \"source-not-found\" | \"invalid-source\" | \"collision\" | \"filesystem\";\n\nexport class PrototypeDuplicateError extends Error {\n readonly kind: PrototypeDuplicateErrorKind;\n\n constructor(kind: PrototypeDuplicateErrorKind) {\n super(`Prototype duplication failed: ${kind}`);\n this.name = \"PrototypeDuplicateError\";\n this.kind = kind;\n }\n}\n\ntype OwnedPath = {\n path: string;\n device: number;\n inode: number;\n};\n\nfunction sourcePathFor(root: string, ref: PrototypeRef) {\n return join(root, ref.login, ref.slug);\n}\n\nasync function ownedPath(path: string): Promise<OwnedPath> {\n const stats = await lstat(path);\n return { path, device: stats.dev, inode: stats.ino };\n}\n\nasync function assertNoSymlinkedSourceParents(\n path: string,\n root: string,\n kind: \"invalid-source\" | \"filesystem\",\n) {\n const segments = relative(root, path).split(sep).filter(Boolean);\n const ancestors = [root];\n let current = root;\n for (const segment of segments.slice(0, -1)) {\n current = join(current, segment);\n ancestors.push(current);\n }\n\n for (const ancestor of ancestors) {\n try {\n if ((await lstat(ancestor)).isSymbolicLink()) {\n throw new PrototypeDuplicateError(kind);\n }\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (isFileNotFound(error)) return;\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n }\n}\n\nasync function assertDirectory(\n path: string,\n kind: \"source-not-found\" | \"invalid-source\" | \"filesystem\",\n sourceRoot?: string,\n) {\n try {\n if (sourceRoot !== undefined) {\n await assertNoSymlinkedSourceParents(path, sourceRoot, \"invalid-source\");\n }\n const stats = await lstat(path);\n if (stats.isSymbolicLink() || !stats.isDirectory()) {\n throw new PrototypeDuplicateError(kind);\n }\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (isFileNotFound(error)) throw new PrototypeDuplicateError(kind);\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function readNoFollow(\n path: string,\n kind: \"invalid-source\" | \"filesystem\",\n) {\n try {\n const stats = await lstat(path);\n if (stats.isSymbolicLink() || !stats.isFile()) {\n throw new PrototypeDuplicateError(kind);\n }\n const file = await open(path, FILE_OPEN_FLAGS);\n try {\n return await file.readFile();\n } finally {\n await file.close();\n }\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (isFileNotFound(error)) return null;\n throw new PrototypeDuplicateError(kind);\n }\n}\n\nfunction isPrunedPath(path: string) {\n const normalizedPath = path.split(sep).join(\"/\");\n const name = normalizedPath.slice(normalizedPath.lastIndexOf(\"/\") + 1);\n // Duplicate has historically excluded every `.env*` file; retain that\n // destination-validation safety rule in addition to the shared policy.\n return (\n name.startsWith(\".env\") ||\n isProtectedPrototypePath(normalizedPath) ||\n FRESH_CONTEXT_NAMES.has(name)\n );\n}\n\n/**\n * Take a no-following snapshot of the authored source files. In particular,\n * the directory walk must not use a permissive extension list: prototypes can\n * contain arbitrary text, data, and binary assets.\n */\nasync function discoverSourceFiles(root: string, current = root) {\n let entries;\n try {\n entries = await readdir(current);\n } catch {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n\n const files = new Map<string, Uint8Array>();\n for (const name of entries.sort((left, right) =>\n left < right ? -1 : left > right ? 1 : 0,\n )) {\n const path = join(current, name);\n const relativePath = relative(root, path).split(sep).join(\"/\");\n // Prune before lstat/readdir so excluded trees are not inspected at all.\n if (isPrunedPath(relativePath)) continue;\n\n let stats;\n try {\n stats = await lstat(path);\n } catch {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n if (stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile())) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n if (stats.isDirectory()) {\n const nested = await discoverSourceFiles(root, path);\n for (const [nestedPath, content] of nested) {\n files.set(nestedPath, content);\n }\n continue;\n }\n\n // Only the root marker is regenerated; nested markers preserve authored\n // directories and are copied like any other regular file.\n if (relativePath === \"components/.gitkeep\") {\n continue;\n }\n const content = await readNoFollow(path, \"invalid-source\");\n if (content === null) throw new PrototypeDuplicateError(\"invalid-source\");\n files.set(relativePath, content);\n }\n return files;\n}\n\nasync function inspectSource(\n sourcePath: string,\n sourceRoot: string,\n themeRegistry?: ThemeRegistry,\n) {\n await assertDirectory(sourcePath, \"source-not-found\", sourceRoot);\n\n try {\n await lstat(join(sourcePath, \"page.module.css\"));\n throw new PrototypeDuplicateError(\"invalid-source\");\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (!isFileNotFound(error)) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n }\n\n const files = await discoverSourceFiles(sourcePath);\n const metadataBytes = files.get(\"metadata.json\");\n if (metadataBytes === undefined) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n let metadata: PrototypeMetadata;\n try {\n metadata = parsePrototypeMetadata(\n JSON.parse(Buffer.from(metadataBytes).toString(\"utf8\")),\n { themeRegistry },\n );\n } catch {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n\n for (const name of [\"page.tsx\", \"app.tsx\", \"app.module.css\"]) {\n if (!files.has(name)) throw new PrototypeDuplicateError(\"invalid-source\");\n }\n\n const componentsPath = join(sourcePath, \"components\");\n try {\n const stats = await lstat(componentsPath);\n if (stats.isSymbolicLink() || !stats.isDirectory()) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (!isFileNotFound(error)) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n }\n\n const componentFiles = [...files.keys()]\n .filter((file) => file.startsWith(\"components/\"))\n .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));\n // `components/` is deliberately represented by the normal empty marker when\n // it has no authored files; all other discovered files are copied as-is.\n return { metadata, files, componentFiles };\n}\n\nasync function reserveDestination(\n path: string,\n paths: Pick<\n ResolvedPrototypeCatalogPaths,\n \"repositoryRoot\" | \"prototypeRoot\"\n >,\n) {\n try {\n await mkdir(path);\n } catch (error) {\n if (\n error !== null &&\n typeof error === \"object\" &&\n \"code\" in error &&\n error.code === \"EEXIST\"\n ) {\n throw new PrototypeDuplicateError(\"collision\");\n }\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n try {\n return await captureOwnedDestination({\n path,\n repositoryRoot: paths.repositoryRoot,\n prototypeRoot: paths.prototypeRoot,\n });\n } catch {\n // The newly created path is retained because ownership was not proven.\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function ensureDirectory(path: string) {\n try {\n await mkdir(path, { recursive: true });\n await assertDirectory(path, \"filesystem\");\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function createDirectory(path: string) {\n try {\n await mkdir(path);\n await assertDirectory(path, \"filesystem\");\n return await ownedPath(path);\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function createNestedDirectories(path: string, existingRoot: string) {\n const missing: string[] = [];\n let current = path;\n while (current !== existingRoot) {\n try {\n const stats = await lstat(current);\n if (stats.isSymbolicLink() || !stats.isDirectory()) {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n break;\n } catch (error) {\n if (error instanceof PrototypeDuplicateError) throw error;\n if (!isFileNotFound(error)) {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n missing.push(current);\n const parent = dirname(current);\n if (parent === current) throw new PrototypeDuplicateError(\"filesystem\");\n current = parent;\n }\n }\n\n const owned: OwnedPath[] = [];\n for (const directory of missing.reverse()) {\n owned.push(await createDirectory(directory));\n }\n return owned;\n}\n\nasync function writeExclusive(\n path: string,\n content: Uint8Array | string,\n writeFile: SafeWriteFile,\n) {\n try {\n await writeFile(path, content, { flag: \"wx\" });\n return await ownedPath(path);\n } catch {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nasync function removeOwnedPaths(\n files: readonly OwnedPath[],\n directories: readonly OwnedPath[],\n) {\n try {\n for (const owned of [...files].reverse()) {\n const current = await lstat(owned.path).catch(() => null);\n if (\n current !== null &&\n !current.isSymbolicLink() &&\n current.dev === owned.device &&\n current.ino === owned.inode\n ) {\n await rm(owned.path, { force: true });\n }\n }\n for (const owned of [...directories].reverse()) {\n const current = await lstat(owned.path).catch(() => null);\n if (\n current !== null &&\n current.isDirectory() &&\n !current.isSymbolicLink() &&\n current.dev === owned.device &&\n current.ino === owned.inode\n ) {\n await rmdir(owned.path);\n }\n }\n } catch {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n\nexport async function duplicatePrototype({\n paths,\n source,\n destination: destinationRef,\n title,\n description,\n authorName,\n themeRegistry,\n clock = () => new Date(),\n writeFile = writeFileAsync,\n}: DuplicatePrototypeOptions): Promise<OwnedDestinationResult> {\n const normalizedSource = definePrototypeRef(source);\n const normalizedDestination = definePrototypeRef(destinationRef);\n const sourcePath = sourcePathFor(paths.prototypeRoot, normalizedSource);\n const destination = sourcePathFor(paths.prototypeRoot, normalizedDestination);\n const sourceDetails = await inspectSource(\n sourcePath,\n paths.prototypeRoot,\n themeRegistry,\n );\n if (sourceDetails.metadata.authorLogin !== normalizedSource.login) {\n throw new PrototypeDuplicateError(\"invalid-source\");\n }\n\n const timestamp = clock().toISOString();\n const metadata = buildMetadataJson(\n title,\n description,\n normalizedDestination.login,\n authorName,\n timestamp,\n sourceDetails.metadata.theme,\n );\n parsePrototypeMetadata(JSON.parse(metadata), { themeRegistry });\n\n await ensureDirectory(paths.prototypeRoot);\n await ensureDirectory(dirname(destination));\n const ownedFiles: OwnedPath[] = [];\n const receipt = await reserveDestination(destination, paths);\n const ownedDirectories: OwnedPath[] = [receipt];\n\n try {\n ownedDirectories.push(\n await createDirectory(join(destination, \"components\")),\n );\n\n const copiedFiles: string[] = [];\n for (const [name, content] of sourceDetails.files) {\n // These files describe the source context and are always regenerated for\n // the destination. Only root files are special; nested files with the\n // same names remain ordinary authored content.\n if (name.includes(\"/\") === false && GENERATED_ROOT_FILES.has(name)) {\n continue;\n }\n const target = join(destination, name);\n ownedDirectories.push(\n ...(await createNestedDirectories(dirname(target), destination)),\n );\n ownedFiles.push(await writeExclusive(target, content, writeFile));\n copiedFiles.push(name);\n }\n\n if (sourceDetails.componentFiles.length === 0) {\n ownedFiles.push(\n await writeExclusive(\n join(destination, \"components/.gitkeep\"),\n \"\",\n writeFile,\n ),\n );\n }\n\n ownedFiles.push(\n await writeExclusive(\n join(destination, \"metadata.json\"),\n metadata,\n writeFile,\n ),\n );\n ownedFiles.push(\n await writeExclusive(\n join(destination, \"tsconfig.json\"),\n buildTsConfig(paths.repositoryRoot, destination),\n writeFile,\n ),\n );\n ownedFiles.push(\n await writeExclusive(\n join(destination, \"css-modules.d.ts\"),\n buildCssModulesDeclaration(),\n writeFile,\n ),\n );\n\n const writtenMetadata = await readNoFollow(\n join(destination, \"metadata.json\"),\n \"filesystem\",\n );\n if (writtenMetadata === null) {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n parsePrototypeMetadata(JSON.parse(writtenMetadata.toString(\"utf8\")), {\n themeRegistry,\n });\n const expectedFiles = [\n ...copiedFiles,\n ...(sourceDetails.componentFiles.length === 0\n ? [\"components/.gitkeep\"]\n : []),\n \"metadata.json\",\n \"tsconfig.json\",\n \"css-modules.d.ts\",\n ];\n return {\n destination,\n receipt,\n expectedFiles: Object.freeze(expectedFiles),\n };\n } catch (error) {\n try {\n await removeOwnedPaths(ownedFiles, ownedDirectories);\n } catch {\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n if (error instanceof PrototypeDuplicateError) throw error;\n throw new PrototypeDuplicateError(\"filesystem\");\n }\n}\n"]}
package/dist/duplicate.js CHANGED
@@ -1,12 +1,20 @@
1
1
  import { constants } from "node:fs";
2
2
  import { lstat, mkdir, open, readdir, rm, rmdir, writeFile as writeFileAsync, } from "node:fs/promises";
3
- import { dirname, join, relative } from "node:path";
3
+ import { dirname, join, relative, sep } from "node:path";
4
4
  import { definePrototypeRef, parsePrototypeMetadata, } from "@ebay-dt/bonfire-core";
5
- import { buildCssModulesDeclaration, buildMetadataJson, buildTsConfig, COPYABLE_IMPLEMENTATION_FILES, } from "./internal/prototype-templates.js";
5
+ import { buildCssModulesDeclaration, buildMetadataJson, buildTsConfig, } from "./internal/prototype-templates.js";
6
6
  import { captureOwnedDestination, } from "./internal/owned-destination.js";
7
7
  import { isFileNotFound } from "./internal/utils.js";
8
+ import { isProtectedPrototypePath } from "./lifecycle/prototype-path-policy.js";
8
9
  const FILE_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
9
- const BLOCKED_COMPONENT_NAMES = new Set([".next", "node_modules"]);
10
+ // These are preparation context, rather than authored prototype content. The
11
+ // credential/runtime exclusions are shared with lifecycle path operations.
12
+ const FRESH_CONTEXT_NAMES = new Set([".claude", "AGENTS.md", "CLAUDE.md"]);
13
+ const GENERATED_ROOT_FILES = new Set([
14
+ "metadata.json",
15
+ "tsconfig.json",
16
+ "css-modules.d.ts",
17
+ ]);
10
18
  export class PrototypeDuplicateError extends Error {
11
19
  kind;
12
20
  constructor(kind) {
@@ -22,8 +30,34 @@ async function ownedPath(path) {
22
30
  const stats = await lstat(path);
23
31
  return { path, device: stats.dev, inode: stats.ino };
24
32
  }
25
- async function assertDirectory(path, kind) {
33
+ async function assertNoSymlinkedSourceParents(path, root, kind) {
34
+ const segments = relative(root, path).split(sep).filter(Boolean);
35
+ const ancestors = [root];
36
+ let current = root;
37
+ for (const segment of segments.slice(0, -1)) {
38
+ current = join(current, segment);
39
+ ancestors.push(current);
40
+ }
41
+ for (const ancestor of ancestors) {
42
+ try {
43
+ if ((await lstat(ancestor)).isSymbolicLink()) {
44
+ throw new PrototypeDuplicateError(kind);
45
+ }
46
+ }
47
+ catch (error) {
48
+ if (error instanceof PrototypeDuplicateError)
49
+ throw error;
50
+ if (isFileNotFound(error))
51
+ return;
52
+ throw new PrototypeDuplicateError("filesystem");
53
+ }
54
+ }
55
+ }
56
+ async function assertDirectory(path, kind, sourceRoot) {
26
57
  try {
58
+ if (sourceRoot !== undefined) {
59
+ await assertNoSymlinkedSourceParents(path, sourceRoot, "invalid-source");
60
+ }
27
61
  const stats = await lstat(path);
28
62
  if (stats.isSymbolicLink() || !stats.isDirectory()) {
29
63
  throw new PrototypeDuplicateError(kind);
@@ -59,24 +93,35 @@ async function readNoFollow(path, kind) {
59
93
  throw new PrototypeDuplicateError(kind);
60
94
  }
61
95
  }
62
- async function readRequiredSourceFile(path) {
63
- const content = await readNoFollow(path, "invalid-source");
64
- if (content === null) {
65
- throw new PrototypeDuplicateError("invalid-source");
66
- }
67
- return content;
96
+ function isPrunedPath(path) {
97
+ const normalizedPath = path.split(sep).join("/");
98
+ const name = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1);
99
+ // Duplicate has historically excluded every `.env*` file; retain that
100
+ // destination-validation safety rule in addition to the shared policy.
101
+ return (name.startsWith(".env") ||
102
+ isProtectedPrototypePath(normalizedPath) ||
103
+ FRESH_CONTEXT_NAMES.has(name));
68
104
  }
69
- async function listComponentFiles(root, current) {
105
+ /**
106
+ * Take a no-following snapshot of the authored source files. In particular,
107
+ * the directory walk must not use a permissive extension list: prototypes can
108
+ * contain arbitrary text, data, and binary assets.
109
+ */
110
+ async function discoverSourceFiles(root, current = root) {
70
111
  let entries;
71
112
  try {
72
- entries = await readdir(current, { withFileTypes: true });
113
+ entries = await readdir(current);
73
114
  }
74
115
  catch {
75
116
  throw new PrototypeDuplicateError("invalid-source");
76
117
  }
77
- const files = [];
78
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
79
- const path = join(current, entry.name);
118
+ const files = new Map();
119
+ for (const name of entries.sort((left, right) => left < right ? -1 : left > right ? 1 : 0)) {
120
+ const path = join(current, name);
121
+ const relativePath = relative(root, path).split(sep).join("/");
122
+ // Prune before lstat/readdir so excluded trees are not inspected at all.
123
+ if (isPrunedPath(relativePath))
124
+ continue;
80
125
  let stats;
81
126
  try {
82
127
  stats = await lstat(path);
@@ -84,27 +129,30 @@ async function listComponentFiles(root, current) {
84
129
  catch {
85
130
  throw new PrototypeDuplicateError("invalid-source");
86
131
  }
87
- if (stats.isSymbolicLink()) {
132
+ if (stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile())) {
88
133
  throw new PrototypeDuplicateError("invalid-source");
89
134
  }
90
- if (entry.name === ".gitkeep" ||
91
- BLOCKED_COMPONENT_NAMES.has(entry.name) ||
92
- entry.name.startsWith(".env")) {
135
+ if (stats.isDirectory()) {
136
+ const nested = await discoverSourceFiles(root, path);
137
+ for (const [nestedPath, content] of nested) {
138
+ files.set(nestedPath, content);
139
+ }
93
140
  continue;
94
141
  }
95
- if (stats.isDirectory()) {
96
- files.push(...(await listComponentFiles(root, path)));
142
+ // Only the root marker is regenerated; nested markers preserve authored
143
+ // directories and are copied like any other regular file.
144
+ if (relativePath === "components/.gitkeep") {
97
145
  continue;
98
146
  }
99
- if (!stats.isFile()) {
147
+ const content = await readNoFollow(path, "invalid-source");
148
+ if (content === null)
100
149
  throw new PrototypeDuplicateError("invalid-source");
101
- }
102
- files.push(relative(root, path));
150
+ files.set(relativePath, content);
103
151
  }
104
152
  return files;
105
153
  }
106
- async function inspectSource(sourcePath, themeRegistry) {
107
- await assertDirectory(sourcePath, "source-not-found");
154
+ async function inspectSource(sourcePath, sourceRoot, themeRegistry) {
155
+ await assertDirectory(sourcePath, "source-not-found", sourceRoot);
108
156
  try {
109
157
  await lstat(join(sourcePath, "page.module.css"));
110
158
  throw new PrototypeDuplicateError("invalid-source");
@@ -116,26 +164,28 @@ async function inspectSource(sourcePath, themeRegistry) {
116
164
  throw new PrototypeDuplicateError("invalid-source");
117
165
  }
118
166
  }
119
- const metadataBytes = await readRequiredSourceFile(join(sourcePath, "metadata.json"));
167
+ const files = await discoverSourceFiles(sourcePath);
168
+ const metadataBytes = files.get("metadata.json");
169
+ if (metadataBytes === undefined) {
170
+ throw new PrototypeDuplicateError("invalid-source");
171
+ }
120
172
  let metadata;
121
173
  try {
122
- metadata = parsePrototypeMetadata(JSON.parse(metadataBytes.toString("utf8")), { themeRegistry });
174
+ metadata = parsePrototypeMetadata(JSON.parse(Buffer.from(metadataBytes).toString("utf8")), { themeRegistry });
123
175
  }
124
176
  catch {
125
177
  throw new PrototypeDuplicateError("invalid-source");
126
178
  }
127
- const implementationFiles = new Map();
128
- for (const name of COPYABLE_IMPLEMENTATION_FILES) {
129
- implementationFiles.set(name, await readRequiredSourceFile(join(sourcePath, name)));
179
+ for (const name of ["page.tsx", "app.tsx", "app.module.css"]) {
180
+ if (!files.has(name))
181
+ throw new PrototypeDuplicateError("invalid-source");
130
182
  }
131
183
  const componentsPath = join(sourcePath, "components");
132
- let componentFiles = [];
133
184
  try {
134
185
  const stats = await lstat(componentsPath);
135
186
  if (stats.isSymbolicLink() || !stats.isDirectory()) {
136
187
  throw new PrototypeDuplicateError("invalid-source");
137
188
  }
138
- componentFiles = await listComponentFiles(componentsPath, componentsPath);
139
189
  }
140
190
  catch (error) {
141
191
  if (error instanceof PrototypeDuplicateError)
@@ -144,7 +194,12 @@ async function inspectSource(sourcePath, themeRegistry) {
144
194
  throw new PrototypeDuplicateError("invalid-source");
145
195
  }
146
196
  }
147
- return { metadata, implementationFiles, componentFiles };
197
+ const componentFiles = [...files.keys()]
198
+ .filter((file) => file.startsWith("components/"))
199
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
200
+ // `components/` is deliberately represented by the normal empty marker when
201
+ // it has no authored files; all other discovered files are copied as-is.
202
+ return { metadata, files, componentFiles };
148
203
  }
149
204
  async function reserveDestination(path, paths) {
150
205
  try {
@@ -264,7 +319,7 @@ export async function duplicatePrototype({ paths, source, destination: destinati
264
319
  const normalizedDestination = definePrototypeRef(destinationRef);
265
320
  const sourcePath = sourcePathFor(paths.prototypeRoot, normalizedSource);
266
321
  const destination = sourcePathFor(paths.prototypeRoot, normalizedDestination);
267
- const sourceDetails = await inspectSource(sourcePath, themeRegistry);
322
+ const sourceDetails = await inspectSource(sourcePath, paths.prototypeRoot, themeRegistry);
268
323
  if (sourceDetails.metadata.authorLogin !== normalizedSource.login) {
269
324
  throw new PrototypeDuplicateError("invalid-source");
270
325
  }
@@ -278,20 +333,22 @@ export async function duplicatePrototype({ paths, source, destination: destinati
278
333
  const ownedDirectories = [receipt];
279
334
  try {
280
335
  ownedDirectories.push(await createDirectory(join(destination, "components")));
281
- for (const name of COPYABLE_IMPLEMENTATION_FILES) {
282
- ownedFiles.push(await writeExclusive(join(destination, name), sourceDetails.implementationFiles.get(name), writeFile));
336
+ const copiedFiles = [];
337
+ for (const [name, content] of sourceDetails.files) {
338
+ // These files describe the source context and are always regenerated for
339
+ // the destination. Only root files are special; nested files with the
340
+ // same names remain ordinary authored content.
341
+ if (name.includes("/") === false && GENERATED_ROOT_FILES.has(name)) {
342
+ continue;
343
+ }
344
+ const target = join(destination, name);
345
+ ownedDirectories.push(...(await createNestedDirectories(dirname(target), destination)));
346
+ ownedFiles.push(await writeExclusive(target, content, writeFile));
347
+ copiedFiles.push(name);
283
348
  }
284
349
  if (sourceDetails.componentFiles.length === 0) {
285
350
  ownedFiles.push(await writeExclusive(join(destination, "components/.gitkeep"), "", writeFile));
286
351
  }
287
- else {
288
- for (const file of sourceDetails.componentFiles) {
289
- const sourceContent = await readRequiredSourceFile(join(sourcePath, "components", file));
290
- const target = join(destination, "components", file);
291
- ownedDirectories.push(...(await createNestedDirectories(dirname(target), join(destination, "components"))));
292
- ownedFiles.push(await writeExclusive(target, sourceContent, writeFile));
293
- }
294
- }
295
352
  ownedFiles.push(await writeExclusive(join(destination, "metadata.json"), metadata, writeFile));
296
353
  ownedFiles.push(await writeExclusive(join(destination, "tsconfig.json"), buildTsConfig(paths.repositoryRoot, destination), writeFile));
297
354
  ownedFiles.push(await writeExclusive(join(destination, "css-modules.d.ts"), buildCssModulesDeclaration(), writeFile));
@@ -303,8 +360,7 @@ export async function duplicatePrototype({ paths, source, destination: destinati
303
360
  themeRegistry,
304
361
  });
305
362
  const expectedFiles = [
306
- ...COPYABLE_IMPLEMENTATION_FILES,
307
- ...sourceDetails.componentFiles.map((file) => join("components", file)),
363
+ ...copiedFiles,
308
364
  ...(sourceDetails.componentFiles.length === 0
309
365
  ? ["components/.gitkeep"]
310
366
  : []),