@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.
- package/README.md +18 -5
- package/dist/deployment-provider.d.ts +2 -1
- package/dist/deployment-provider.d.ts.map +1 -1
- package/dist/deployment-provider.js +2 -1
- package/dist/deployment-provider.js.map +1 -1
- package/dist/duplicate.d.ts.map +1 -1
- package/dist/duplicate.js +103 -47
- package/dist/duplicate.js.map +1 -1
- package/dist/git-repository.d.ts +16 -1
- package/dist/git-repository.d.ts.map +1 -1
- package/dist/git-repository.js +127 -1
- package/dist/git-repository.js.map +1 -1
- package/dist/github-cli.d.ts.map +1 -1
- package/dist/github-cli.js +3 -1
- package/dist/github-cli.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/prototype-templates.d.ts +0 -1
- package/dist/internal/prototype-templates.d.ts.map +1 -1
- package/dist/internal/prototype-templates.js +0 -5
- package/dist/internal/prototype-templates.js.map +1 -1
- package/dist/lifecycle/deployment-worktree.d.ts +36 -0
- package/dist/lifecycle/deployment-worktree.d.ts.map +1 -0
- package/dist/lifecycle/deployment-worktree.js +503 -0
- package/dist/lifecycle/deployment-worktree.js.map +1 -0
- package/dist/lifecycle/local-prototype-lifecycle.d.ts +15 -1
- package/dist/lifecycle/local-prototype-lifecycle.d.ts.map +1 -1
- package/dist/lifecycle/local-prototype-lifecycle.js +738 -98
- package/dist/lifecycle/local-prototype-lifecycle.js.map +1 -1
- package/dist/lifecycle/preparation-marker.d.ts +12 -0
- package/dist/lifecycle/preparation-marker.d.ts.map +1 -0
- package/dist/lifecycle/preparation-marker.js +76 -0
- package/dist/lifecycle/preparation-marker.js.map +1 -0
- package/dist/lifecycle/prototype-path-policy.d.ts +18 -0
- package/dist/lifecycle/prototype-path-policy.d.ts.map +1 -0
- package/dist/lifecycle/prototype-path-policy.js +429 -0
- package/dist/lifecycle/prototype-path-policy.js.map +1 -0
- package/dist/lifecycle/repository-summary.d.ts +7 -1
- package/dist/lifecycle/repository-summary.d.ts.map +1 -1
- package/dist/lifecycle/repository-summary.js +20 -4
- package/dist/lifecycle/repository-summary.js.map +1 -1
- package/dist/lifecycle/repository-synchronization.d.ts +13 -8
- package/dist/lifecycle/repository-synchronization.d.ts.map +1 -1
- package/dist/lifecycle/repository-synchronization.js +680 -139
- package/dist/lifecycle/repository-synchronization.js.map +1 -1
- package/dist/lifecycle/runtime-context.d.ts +2 -0
- package/dist/lifecycle/runtime-context.d.ts.map +1 -1
- package/dist/lifecycle/runtime-context.js +5 -1
- package/dist/lifecycle/runtime-context.js.map +1 -1
- 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.
|
|
121
|
-
identity branch
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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;
|
|
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("
|
|
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"]}
|
package/dist/duplicate.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
79
|
-
const path = join(current,
|
|
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 (
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
...
|
|
307
|
-
...sourceDetails.componentFiles.map((file) => join("components", file)),
|
|
363
|
+
...copiedFiles,
|
|
308
364
|
...(sourceDetails.componentFiles.length === 0
|
|
309
365
|
? ["components/.gitkeep"]
|
|
310
366
|
: []),
|