@wp-typia/project-tools 0.20.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/ai-feature-capability.js +2 -33
- package/dist/runtime/built-in-block-artifact-types.js +11 -0
- package/dist/runtime/built-in-block-code-artifacts.js +5 -1
- package/dist/runtime/built-in-block-code-templates/interactivity.d.ts +4 -3
- package/dist/runtime/built-in-block-code-templates/interactivity.js +259 -100
- package/dist/runtime/built-in-block-code-templates.d.ts +1 -1
- package/dist/runtime/built-in-block-code-templates.js +1 -1
- package/dist/runtime/cli-add-shared.d.ts +74 -5
- package/dist/runtime/cli-add-shared.js +61 -11
- package/dist/runtime/cli-add-workspace-ability.js +14 -61
- package/dist/runtime/cli-add-workspace-admin-view.d.ts +25 -0
- package/dist/runtime/cli-add-workspace-admin-view.js +1401 -0
- package/dist/runtime/cli-add-workspace-ai-anchors.js +2 -5
- package/dist/runtime/cli-add-workspace-ai-source-emitters.d.ts +0 -4
- package/dist/runtime/cli-add-workspace-ai-source-emitters.js +7 -17
- package/dist/runtime/cli-add-workspace-ai.js +4 -6
- package/dist/runtime/cli-add-workspace-assets.d.ts +13 -5
- package/dist/runtime/cli-add-workspace-assets.js +290 -106
- package/dist/runtime/cli-add-workspace-rest-anchors.js +2 -5
- package/dist/runtime/cli-add-workspace-rest-source-emitters.d.ts +0 -1
- package/dist/runtime/cli-add-workspace-rest-source-emitters.js +7 -14
- package/dist/runtime/cli-add-workspace-rest.js +4 -6
- package/dist/runtime/cli-add-workspace.d.ts +58 -1
- package/dist/runtime/cli-add-workspace.js +588 -18
- package/dist/runtime/cli-add.d.ts +1 -1
- package/dist/runtime/cli-add.js +1 -1
- package/dist/runtime/cli-core.d.ts +8 -5
- package/dist/runtime/cli-core.js +7 -4
- package/dist/runtime/cli-diagnostics.d.ts +83 -1
- package/dist/runtime/cli-diagnostics.js +85 -2
- package/dist/runtime/cli-doctor-workspace.js +553 -13
- package/dist/runtime/cli-doctor.d.ts +4 -2
- package/dist/runtime/cli-doctor.js +2 -1
- package/dist/runtime/cli-help.js +22 -9
- package/dist/runtime/cli-init.d.ts +67 -3
- package/dist/runtime/cli-init.js +603 -64
- package/dist/runtime/cli-validation.js +4 -3
- package/dist/runtime/external-layer-selection.d.ts +8 -2
- package/dist/runtime/external-layer-selection.js +3 -4
- package/dist/runtime/index.d.ts +9 -4
- package/dist/runtime/index.js +7 -3
- package/dist/runtime/package-json-types.d.ts +12 -0
- package/dist/runtime/package-json-types.js +1 -0
- package/dist/runtime/package-versions.d.ts +30 -2
- package/dist/runtime/package-versions.js +59 -1
- package/dist/runtime/php-utils.d.ts +16 -0
- package/dist/runtime/php-utils.js +59 -0
- package/dist/runtime/scaffold-answer-resolution.js +7 -6
- package/dist/runtime/scaffold-apply-utils.d.ts +2 -3
- package/dist/runtime/scaffold-apply-utils.js +3 -43
- package/dist/runtime/scaffold-compatibility.d.ts +2 -2
- package/dist/runtime/scaffold-compatibility.js +22 -48
- package/dist/runtime/template-source-cache.d.ts +112 -0
- package/dist/runtime/template-source-cache.js +434 -0
- package/dist/runtime/template-source-seeds.js +319 -53
- package/dist/runtime/version-floor.d.ts +26 -0
- package/dist/runtime/version-floor.js +56 -0
- package/dist/runtime/workspace-inventory.d.ts +44 -2
- package/dist/runtime/workspace-inventory.js +138 -5
- package/package.json +4 -3
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* The policy keeps plugin headers, runtime gates, and workspace inventory
|
|
5
5
|
* metadata aligned when optional or required AI-capable features are added.
|
|
6
6
|
*/
|
|
7
|
-
import { AI_FEATURE_DEFINITIONS, resolveAiFeatureCapabilityPlan, } from
|
|
7
|
+
import { AI_FEATURE_DEFINITIONS, resolveAiFeatureCapabilityPlan, } from './ai-feature-capability.js';
|
|
8
|
+
import { pickHigherVersionFloor } from './version-floor.js';
|
|
8
9
|
/**
|
|
9
10
|
* Baseline headers used by scaffold output before optional features are added.
|
|
10
11
|
*/
|
|
11
12
|
export const DEFAULT_SCAFFOLD_COMPATIBILITY = {
|
|
12
|
-
requiresAtLeast:
|
|
13
|
-
requiresPhp:
|
|
14
|
-
testedUpTo:
|
|
13
|
+
requiresAtLeast: '6.7',
|
|
14
|
+
requiresPhp: '8.0',
|
|
15
|
+
testedUpTo: '6.9',
|
|
15
16
|
};
|
|
16
17
|
/**
|
|
17
18
|
* Optional WordPress AI Client surface used by server-only AI feature scaffold.
|
|
@@ -19,7 +20,7 @@ export const DEFAULT_SCAFFOLD_COMPATIBILITY = {
|
|
|
19
20
|
export const OPTIONAL_WORDPRESS_AI_CLIENT_COMPATIBILITY = [
|
|
20
21
|
{
|
|
21
22
|
featureId: AI_FEATURE_DEFINITIONS.wordpressAiClient.id,
|
|
22
|
-
mode:
|
|
23
|
+
mode: 'optional',
|
|
23
24
|
},
|
|
24
25
|
];
|
|
25
26
|
/**
|
|
@@ -28,42 +29,15 @@ export const OPTIONAL_WORDPRESS_AI_CLIENT_COMPATIBILITY = [
|
|
|
28
29
|
export const REQUIRED_WORKSPACE_ABILITY_COMPATIBILITY = [
|
|
29
30
|
{
|
|
30
31
|
featureId: AI_FEATURE_DEFINITIONS.wordpressServerAbilities.id,
|
|
31
|
-
mode:
|
|
32
|
+
mode: 'required',
|
|
32
33
|
},
|
|
33
34
|
{
|
|
34
35
|
featureId: AI_FEATURE_DEFINITIONS.wordpressCoreAbilities.id,
|
|
35
|
-
mode:
|
|
36
|
+
mode: 'required',
|
|
36
37
|
},
|
|
37
38
|
];
|
|
38
|
-
function
|
|
39
|
-
return
|
|
40
|
-
if (!/^\d+$/u.test(part)) {
|
|
41
|
-
throw new Error(`parseVersionFloorParts received an invalid version floor "${value}" at segment ${index + 1}.`);
|
|
42
|
-
}
|
|
43
|
-
return Number.parseInt(part, 10);
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
function compareVersionFloors(left, right) {
|
|
47
|
-
const leftParts = parseVersionFloorParts(left);
|
|
48
|
-
const rightParts = parseVersionFloorParts(right);
|
|
49
|
-
const length = Math.max(leftParts.length, rightParts.length);
|
|
50
|
-
for (let index = 0; index < length; index += 1) {
|
|
51
|
-
const leftValue = leftParts[index] ?? 0;
|
|
52
|
-
const rightValue = rightParts[index] ?? 0;
|
|
53
|
-
if (leftValue > rightValue) {
|
|
54
|
-
return 1;
|
|
55
|
-
}
|
|
56
|
-
if (leftValue < rightValue) {
|
|
57
|
-
return -1;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return 0;
|
|
61
|
-
}
|
|
62
|
-
function pickHigherVersionFloor(current, candidate) {
|
|
63
|
-
if (!candidate) {
|
|
64
|
-
return current;
|
|
65
|
-
}
|
|
66
|
-
return compareVersionFloors(current, candidate) >= 0 ? current : candidate;
|
|
39
|
+
function pickHigherScaffoldVersionFloor(current, candidate) {
|
|
40
|
+
return pickHigherVersionFloor(current, candidate) ?? current;
|
|
67
41
|
}
|
|
68
42
|
function pickHigherHeaderVersionFloor(policyValue, currentValue) {
|
|
69
43
|
const normalizedCurrentValue = currentValue.trim();
|
|
@@ -71,7 +45,7 @@ function pickHigherHeaderVersionFloor(policyValue, currentValue) {
|
|
|
71
45
|
return policyValue;
|
|
72
46
|
}
|
|
73
47
|
try {
|
|
74
|
-
return
|
|
48
|
+
return pickHigherScaffoldVersionFloor(policyValue, normalizedCurrentValue);
|
|
75
49
|
}
|
|
76
50
|
catch {
|
|
77
51
|
return policyValue;
|
|
@@ -82,21 +56,21 @@ function formatRuntimeGate(feature) {
|
|
|
82
56
|
}
|
|
83
57
|
function getPolicyMode(capabilityPlan) {
|
|
84
58
|
if (capabilityPlan.requiredFeatures.length > 0) {
|
|
85
|
-
return
|
|
59
|
+
return 'required';
|
|
86
60
|
}
|
|
87
61
|
if (capabilityPlan.optionalFeatures.length > 0) {
|
|
88
|
-
return
|
|
62
|
+
return 'optional';
|
|
89
63
|
}
|
|
90
|
-
return
|
|
64
|
+
return 'baseline';
|
|
91
65
|
}
|
|
92
66
|
/**
|
|
93
67
|
* Resolve plugin header floors and capability gates for scaffold selections.
|
|
94
68
|
*/
|
|
95
69
|
export function resolveScaffoldCompatibilityPolicy(selections, { baseline = DEFAULT_SCAFFOLD_COMPATIBILITY, } = {}) {
|
|
96
70
|
const capabilityPlan = resolveAiFeatureCapabilityPlan(selections);
|
|
97
|
-
const requiresAtLeast =
|
|
98
|
-
const requiresPhp =
|
|
99
|
-
const testedUpTo =
|
|
71
|
+
const requiresAtLeast = pickHigherScaffoldVersionFloor(baseline.requiresAtLeast, capabilityPlan.hardMinimums.wordpress);
|
|
72
|
+
const requiresPhp = pickHigherScaffoldVersionFloor(baseline.requiresPhp, capabilityPlan.hardMinimums.php);
|
|
73
|
+
const testedUpTo = pickHigherScaffoldVersionFloor(baseline.testedUpTo, requiresAtLeast);
|
|
100
74
|
return {
|
|
101
75
|
capabilityPlan,
|
|
102
76
|
pluginHeader: {
|
|
@@ -125,16 +99,16 @@ export function createScaffoldCompatibilityConfig(policy) {
|
|
|
125
99
|
/**
|
|
126
100
|
* Render compatibility metadata as formatted TypeScript object literal JSON.
|
|
127
101
|
*/
|
|
128
|
-
export function renderScaffoldCompatibilityConfig(policy, indent =
|
|
102
|
+
export function renderScaffoldCompatibilityConfig(policy, indent = '\t\t') {
|
|
129
103
|
const config = createScaffoldCompatibilityConfig(policy);
|
|
130
|
-
return JSON.stringify(config, null,
|
|
131
|
-
.split(
|
|
104
|
+
return JSON.stringify(config, null, '\t')
|
|
105
|
+
.split('\n')
|
|
132
106
|
.map((line, index) => (index === 0 ? line : `${indent}${line}`))
|
|
133
|
-
.join(
|
|
107
|
+
.join('\n');
|
|
134
108
|
}
|
|
135
109
|
function replacePluginHeaderVersionFloor(source, pattern, policyValue) {
|
|
136
110
|
return source.replace(pattern, (_match, prefix, currentValue, lineEnding) => {
|
|
137
|
-
const versionPrefix = prefix.endsWith(
|
|
111
|
+
const versionPrefix = prefix.endsWith(':') ? `${prefix} ` : prefix;
|
|
138
112
|
return `${versionPrefix}${pickHigherHeaderVersionFloor(policyValue, currentValue)}${lineEnding}`;
|
|
139
113
|
});
|
|
140
114
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variable that disables external template cache reads and writes.
|
|
3
|
+
*
|
|
4
|
+
* Set to `0`, `false`, `no`, or `off` to bypass the cache.
|
|
5
|
+
*/
|
|
6
|
+
export declare const EXTERNAL_TEMPLATE_CACHE_ENV = "WP_TYPIA_EXTERNAL_TEMPLATE_CACHE";
|
|
7
|
+
/**
|
|
8
|
+
* Environment variable that overrides the external template cache root.
|
|
9
|
+
*/
|
|
10
|
+
export declare const EXTERNAL_TEMPLATE_CACHE_DIR_ENV = "WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_DIR";
|
|
11
|
+
/**
|
|
12
|
+
* Serializable metadata recorded in the cache marker for diagnostics.
|
|
13
|
+
*/
|
|
14
|
+
type ExternalTemplateCacheMetadata = Record<string, string | null>;
|
|
15
|
+
/**
|
|
16
|
+
* Describes a deterministic external template cache entry.
|
|
17
|
+
*
|
|
18
|
+
* `namespace` scopes independent cache families, `keyParts` identify the exact
|
|
19
|
+
* source/integrity tuple, and `metadata` is persisted to the marker file.
|
|
20
|
+
*/
|
|
21
|
+
export interface ExternalTemplateCacheDescriptor {
|
|
22
|
+
/**
|
|
23
|
+
* Ordered values that deterministically identify one cached template source.
|
|
24
|
+
*/
|
|
25
|
+
keyParts: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Diagnostic values persisted to the cache marker after sanitization.
|
|
28
|
+
*/
|
|
29
|
+
metadata: ExternalTemplateCacheMetadata;
|
|
30
|
+
/**
|
|
31
|
+
* Cache family scope, stored as a single safe directory segment.
|
|
32
|
+
*/
|
|
33
|
+
namespace: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Result returned when a cache entry is reused or populated.
|
|
37
|
+
*/
|
|
38
|
+
export interface ExternalTemplateCacheResolution {
|
|
39
|
+
/**
|
|
40
|
+
* Whether the returned source directory came from an existing cache entry.
|
|
41
|
+
*/
|
|
42
|
+
cacheHit: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Populated or reused template source directory.
|
|
45
|
+
*/
|
|
46
|
+
sourceDir: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Metadata-only lookup descriptor for finding an existing reusable cache entry.
|
|
50
|
+
*/
|
|
51
|
+
export interface ExternalTemplateCacheLookupDescriptor {
|
|
52
|
+
/**
|
|
53
|
+
* Metadata fields that must match the sanitized marker metadata.
|
|
54
|
+
*/
|
|
55
|
+
metadata: ExternalTemplateCacheMetadata;
|
|
56
|
+
/**
|
|
57
|
+
* Cache family scope, stored as a single safe directory segment.
|
|
58
|
+
*/
|
|
59
|
+
namespace: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Checks whether remote external template source caching is enabled.
|
|
63
|
+
*
|
|
64
|
+
* Caching is enabled by default. Set `WP_TYPIA_EXTERNAL_TEMPLATE_CACHE` to
|
|
65
|
+
* `0`, `false`, `no`, or `off` to force uncached resolution.
|
|
66
|
+
*
|
|
67
|
+
* @param env Environment object to inspect, defaulting to `process.env`.
|
|
68
|
+
* @returns Whether external template source cache reads and writes are enabled.
|
|
69
|
+
*/
|
|
70
|
+
export declare function isExternalTemplateCacheEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Resolves the external template source cache root directory.
|
|
73
|
+
*
|
|
74
|
+
* `WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_DIR` overrides the location. Without an
|
|
75
|
+
* override, wp-typia uses a per-user `wp-typia-template-source-cache-*`
|
|
76
|
+
* directory inside the operating system temp directory.
|
|
77
|
+
*
|
|
78
|
+
* @param env Environment object to inspect, defaulting to `process.env`.
|
|
79
|
+
* @returns Absolute cache root directory path.
|
|
80
|
+
*/
|
|
81
|
+
export declare function getExternalTemplateCacheRoot(env?: NodeJS.ProcessEnv): string;
|
|
82
|
+
/**
|
|
83
|
+
* Creates a deterministic cache key from source identity and integrity parts.
|
|
84
|
+
*
|
|
85
|
+
* @param keyParts Ordered values that identify one cached template source.
|
|
86
|
+
* @returns SHA-256 hex digest of the JSON-serialized key parts.
|
|
87
|
+
*/
|
|
88
|
+
export declare function createExternalTemplateCacheKey(keyParts: readonly string[]): string;
|
|
89
|
+
/**
|
|
90
|
+
* Finds a reusable cache entry whose marker metadata includes the expected fields.
|
|
91
|
+
*
|
|
92
|
+
* This lookup is intended for resilient fallbacks where a caller cannot compute
|
|
93
|
+
* the exact deterministic key but can safely reuse a previously validated local
|
|
94
|
+
* cache entry for the same source identity.
|
|
95
|
+
*
|
|
96
|
+
* @param descriptor Cache namespace and marker metadata fields to match.
|
|
97
|
+
* @returns Existing cache resolution details, or `null` when no safe entry exists.
|
|
98
|
+
*/
|
|
99
|
+
export declare function findReusableExternalTemplateSourceCache(descriptor: ExternalTemplateCacheLookupDescriptor): Promise<ExternalTemplateCacheResolution | null>;
|
|
100
|
+
/**
|
|
101
|
+
* Resolves or populates a cached external template source directory.
|
|
102
|
+
*
|
|
103
|
+
* Returns `null` when caching is disabled. Cache misses populate a temporary
|
|
104
|
+
* directory first and then atomically move it into place; concurrent writers
|
|
105
|
+
* that lose the race reuse the completed marker/source pair.
|
|
106
|
+
*
|
|
107
|
+
* @param descriptor Namespace, key parts, and metadata for the cache entry.
|
|
108
|
+
* @param populateSourceDir Callback that writes the guarded source on a miss.
|
|
109
|
+
* @returns Cache resolution details, or `null` when caching is disabled.
|
|
110
|
+
*/
|
|
111
|
+
export declare function resolveExternalTemplateSourceCache(descriptor: ExternalTemplateCacheDescriptor, populateSourceDir: (sourceDir: string) => Promise<void>): Promise<ExternalTemplateCacheResolution | null>;
|
|
112
|
+
export {};
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { promises as fsp } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* Environment variable that disables external template cache reads and writes.
|
|
8
|
+
*
|
|
9
|
+
* Set to `0`, `false`, `no`, or `off` to bypass the cache.
|
|
10
|
+
*/
|
|
11
|
+
export const EXTERNAL_TEMPLATE_CACHE_ENV = 'WP_TYPIA_EXTERNAL_TEMPLATE_CACHE';
|
|
12
|
+
/**
|
|
13
|
+
* Environment variable that overrides the external template cache root.
|
|
14
|
+
*/
|
|
15
|
+
export const EXTERNAL_TEMPLATE_CACHE_DIR_ENV = 'WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_DIR';
|
|
16
|
+
/**
|
|
17
|
+
* Marker file written after a cache entry is fully populated.
|
|
18
|
+
*/
|
|
19
|
+
const CACHE_MARKER_FILE = 'wp-typia-template-cache.json';
|
|
20
|
+
/**
|
|
21
|
+
* Private directory mode used for cache roots and entries on POSIX platforms.
|
|
22
|
+
*/
|
|
23
|
+
const PRIVATE_CACHE_DIRECTORY_MODE = 0o700;
|
|
24
|
+
/**
|
|
25
|
+
* Marker value used when URL-like metadata cannot be safely normalized.
|
|
26
|
+
*/
|
|
27
|
+
const REDACTED_CACHE_METADATA_VALUE = '[redacted]';
|
|
28
|
+
/**
|
|
29
|
+
* Normalized environment values that disable the cache.
|
|
30
|
+
*/
|
|
31
|
+
const DISABLED_CACHE_VALUES = new Set(['0', 'false', 'no', 'off']);
|
|
32
|
+
/**
|
|
33
|
+
* Filesystem errors that mean another writer published the same cache entry.
|
|
34
|
+
*/
|
|
35
|
+
const CACHE_PUBLISH_RACE_ERROR_CODES = new Set(['EEXIST', 'ENOTEMPTY']);
|
|
36
|
+
/**
|
|
37
|
+
* Filesystem errors that make the optional cache unavailable.
|
|
38
|
+
*/
|
|
39
|
+
const CACHE_UNAVAILABLE_ERROR_CODES = new Set([
|
|
40
|
+
'EACCES',
|
|
41
|
+
'ENOSPC',
|
|
42
|
+
'ENOTDIR',
|
|
43
|
+
'EPERM',
|
|
44
|
+
'EROFS',
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Metadata fields that may contain credentialed or signed URLs.
|
|
48
|
+
*/
|
|
49
|
+
const URL_LIKE_METADATA_KEY = /(url|uri|registry|tarball)/iu;
|
|
50
|
+
/**
|
|
51
|
+
* Cache namespaces must stay within one path segment under the cache root.
|
|
52
|
+
*/
|
|
53
|
+
const SAFE_CACHE_NAMESPACE_SEGMENT = /^[A-Za-z0-9_.-]+$/u;
|
|
54
|
+
/**
|
|
55
|
+
* Checks whether remote external template source caching is enabled.
|
|
56
|
+
*
|
|
57
|
+
* Caching is enabled by default. Set `WP_TYPIA_EXTERNAL_TEMPLATE_CACHE` to
|
|
58
|
+
* `0`, `false`, `no`, or `off` to force uncached resolution.
|
|
59
|
+
*
|
|
60
|
+
* @param env Environment object to inspect, defaulting to `process.env`.
|
|
61
|
+
* @returns Whether external template source cache reads and writes are enabled.
|
|
62
|
+
*/
|
|
63
|
+
export function isExternalTemplateCacheEnabled(env = process.env) {
|
|
64
|
+
const rawValue = env[EXTERNAL_TEMPLATE_CACHE_ENV];
|
|
65
|
+
if (rawValue === undefined) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return !DISABLED_CACHE_VALUES.has(rawValue.trim().toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolves the external template source cache root directory.
|
|
72
|
+
*
|
|
73
|
+
* `WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_DIR` overrides the location. Without an
|
|
74
|
+
* override, wp-typia uses a per-user `wp-typia-template-source-cache-*`
|
|
75
|
+
* directory inside the operating system temp directory.
|
|
76
|
+
*
|
|
77
|
+
* @param env Environment object to inspect, defaulting to `process.env`.
|
|
78
|
+
* @returns Absolute cache root directory path.
|
|
79
|
+
*/
|
|
80
|
+
export function getExternalTemplateCacheRoot(env = process.env) {
|
|
81
|
+
const configuredCacheDir = env[EXTERNAL_TEMPLATE_CACHE_DIR_ENV]?.trim();
|
|
82
|
+
if (configuredCacheDir) {
|
|
83
|
+
return path.resolve(configuredCacheDir);
|
|
84
|
+
}
|
|
85
|
+
return path.join(os.tmpdir(), `wp-typia-template-source-cache-${getCurrentUserCacheSegment()}`);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Creates a deterministic cache key from source identity and integrity parts.
|
|
89
|
+
*
|
|
90
|
+
* @param keyParts Ordered values that identify one cached template source.
|
|
91
|
+
* @returns SHA-256 hex digest of the JSON-serialized key parts.
|
|
92
|
+
*/
|
|
93
|
+
export function createExternalTemplateCacheKey(keyParts) {
|
|
94
|
+
return createHash('sha256')
|
|
95
|
+
.update(JSON.stringify(keyParts))
|
|
96
|
+
.digest('hex');
|
|
97
|
+
}
|
|
98
|
+
async function pathExists(filePath) {
|
|
99
|
+
try {
|
|
100
|
+
await fsp.access(filePath, fs.constants.F_OK);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function isDirectoryPath(directory) {
|
|
108
|
+
try {
|
|
109
|
+
const stats = await fsp.lstat(directory);
|
|
110
|
+
return stats.isDirectory() && !stats.isSymbolicLink();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function getNodeErrorCode(error) {
|
|
117
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
118
|
+
? String(error.code)
|
|
119
|
+
: '';
|
|
120
|
+
}
|
|
121
|
+
async function removeTemporaryCacheEntry(entryDir) {
|
|
122
|
+
try {
|
|
123
|
+
await fsp.rm(entryDir, { force: true, recursive: true });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Cache cleanup is best-effort; the caller can still continue uncached.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function getCurrentUserCacheSegment() {
|
|
130
|
+
if (typeof process.getuid === 'function') {
|
|
131
|
+
return String(process.getuid());
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const safeUsername = os
|
|
135
|
+
.userInfo()
|
|
136
|
+
.username.trim()
|
|
137
|
+
.replace(/[^A-Za-z0-9._-]+/gu, '-');
|
|
138
|
+
return safeUsername.length > 0 ? safeUsername : 'user';
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return 'user';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function getCurrentUid() {
|
|
145
|
+
return typeof process.getuid === 'function' ? process.getuid() : null;
|
|
146
|
+
}
|
|
147
|
+
async function isPrivateCacheDirectory(directory) {
|
|
148
|
+
try {
|
|
149
|
+
const stats = await fsp.lstat(directory);
|
|
150
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const currentUid = getCurrentUid();
|
|
154
|
+
if (currentUid !== null && stats.uid !== currentUid) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function ensurePrivateCacheDirectory(directory) {
|
|
167
|
+
try {
|
|
168
|
+
await fsp.mkdir(directory, {
|
|
169
|
+
mode: PRIVATE_CACHE_DIRECTORY_MODE,
|
|
170
|
+
recursive: true,
|
|
171
|
+
});
|
|
172
|
+
const stats = await fsp.lstat(directory);
|
|
173
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const currentUid = getCurrentUid();
|
|
177
|
+
if (currentUid !== null && stats.uid !== currentUid) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (process.platform !== 'win32') {
|
|
181
|
+
if ((stats.mode & 0o077) !== 0) {
|
|
182
|
+
await fsp.chmod(directory, PRIVATE_CACHE_DIRECTORY_MODE);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return isPrivateCacheDirectory(directory);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function sanitizeCacheMetadataValue(key, value) {
|
|
192
|
+
if (!URL_LIKE_METADATA_KEY.test(key)) {
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const url = new URL(value);
|
|
197
|
+
url.username = '';
|
|
198
|
+
url.password = '';
|
|
199
|
+
url.search = '';
|
|
200
|
+
url.hash = '';
|
|
201
|
+
return url.toString();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return REDACTED_CACHE_METADATA_VALUE;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function sanitizeCacheMetadata(metadata) {
|
|
208
|
+
return Object.fromEntries(Object.entries(metadata).map(([key, value]) => [
|
|
209
|
+
key,
|
|
210
|
+
value === null ? null : sanitizeCacheMetadataValue(key, value),
|
|
211
|
+
]));
|
|
212
|
+
}
|
|
213
|
+
function resolveCacheNamespaceDir(cacheRoot, namespace) {
|
|
214
|
+
if (namespace === '.' ||
|
|
215
|
+
namespace === '..' ||
|
|
216
|
+
!SAFE_CACHE_NAMESPACE_SEGMENT.test(namespace)) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const namespaceDir = path.join(cacheRoot, namespace);
|
|
220
|
+
const relativeNamespaceDir = path.relative(cacheRoot, namespaceDir);
|
|
221
|
+
if (relativeNamespaceDir.length === 0 ||
|
|
222
|
+
relativeNamespaceDir.startsWith('..') ||
|
|
223
|
+
path.isAbsolute(relativeNamespaceDir)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return namespaceDir;
|
|
227
|
+
}
|
|
228
|
+
function getCacheEntryPaths(descriptor) {
|
|
229
|
+
const cacheKey = createExternalTemplateCacheKey(descriptor.keyParts);
|
|
230
|
+
const cacheRoot = getExternalTemplateCacheRoot();
|
|
231
|
+
const namespaceDir = resolveCacheNamespaceDir(cacheRoot, descriptor.namespace);
|
|
232
|
+
if (!namespaceDir) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
const entryDir = path.join(namespaceDir, cacheKey);
|
|
236
|
+
return {
|
|
237
|
+
cacheKey,
|
|
238
|
+
cacheRoot,
|
|
239
|
+
entryDir,
|
|
240
|
+
markerPath: path.join(entryDir, CACHE_MARKER_FILE),
|
|
241
|
+
namespaceDir,
|
|
242
|
+
sourceDir: path.join(entryDir, 'source'),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async function isReusableCacheEntry(entryDir, markerPath, sourceDir) {
|
|
246
|
+
return ((await isPrivateCacheDirectory(entryDir)) &&
|
|
247
|
+
(await pathExists(markerPath)) &&
|
|
248
|
+
(await isDirectoryPath(sourceDir)));
|
|
249
|
+
}
|
|
250
|
+
function parseCacheMarkerMetadata(markerText) {
|
|
251
|
+
let marker;
|
|
252
|
+
try {
|
|
253
|
+
marker = JSON.parse(markerText);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (typeof marker !== 'object' || marker === null || Array.isArray(marker)) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const rawMetadata = marker.metadata;
|
|
262
|
+
if (typeof rawMetadata !== 'object' ||
|
|
263
|
+
rawMetadata === null ||
|
|
264
|
+
Array.isArray(rawMetadata)) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const metadata = {};
|
|
268
|
+
for (const [key, value] of Object.entries(rawMetadata)) {
|
|
269
|
+
if (typeof value !== 'string' && value !== null) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
metadata[key] = value;
|
|
273
|
+
}
|
|
274
|
+
const rawCreatedAt = marker.createdAt;
|
|
275
|
+
const createdAtMs = typeof rawCreatedAt === 'string' ? Date.parse(rawCreatedAt) : 0;
|
|
276
|
+
return {
|
|
277
|
+
createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : 0,
|
|
278
|
+
metadata,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function cacheMetadataMatches(actual, expected) {
|
|
282
|
+
return Object.entries(expected).every(([key, value]) => actual[key] === value);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Finds a reusable cache entry whose marker metadata includes the expected fields.
|
|
286
|
+
*
|
|
287
|
+
* This lookup is intended for resilient fallbacks where a caller cannot compute
|
|
288
|
+
* the exact deterministic key but can safely reuse a previously validated local
|
|
289
|
+
* cache entry for the same source identity.
|
|
290
|
+
*
|
|
291
|
+
* @param descriptor Cache namespace and marker metadata fields to match.
|
|
292
|
+
* @returns Existing cache resolution details, or `null` when no safe entry exists.
|
|
293
|
+
*/
|
|
294
|
+
export async function findReusableExternalTemplateSourceCache(descriptor) {
|
|
295
|
+
if (!isExternalTemplateCacheEnabled()) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const cacheRoot = getExternalTemplateCacheRoot();
|
|
299
|
+
const namespaceDir = resolveCacheNamespaceDir(cacheRoot, descriptor.namespace);
|
|
300
|
+
if (!namespaceDir) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
if (!(await isPrivateCacheDirectory(cacheRoot)) ||
|
|
304
|
+
!(await isPrivateCacheDirectory(namespaceDir))) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
let entries;
|
|
308
|
+
try {
|
|
309
|
+
entries = await fsp.readdir(namespaceDir, { withFileTypes: true });
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
let bestEntry = null;
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
if (!entry.isDirectory()) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const entryDir = path.join(namespaceDir, entry.name);
|
|
320
|
+
const markerPath = path.join(entryDir, CACHE_MARKER_FILE);
|
|
321
|
+
const sourceDir = path.join(entryDir, 'source');
|
|
322
|
+
if (!(await isReusableCacheEntry(entryDir, markerPath, sourceDir))) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
let markerText;
|
|
326
|
+
try {
|
|
327
|
+
markerText = await fsp.readFile(markerPath, 'utf8');
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const marker = parseCacheMarkerMetadata(markerText);
|
|
333
|
+
if (!marker || !cacheMetadataMatches(marker.metadata, descriptor.metadata)) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (!bestEntry || marker.createdAtMs > bestEntry.createdAtMs) {
|
|
337
|
+
bestEntry = {
|
|
338
|
+
createdAtMs: marker.createdAtMs,
|
|
339
|
+
sourceDir,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return bestEntry
|
|
344
|
+
? {
|
|
345
|
+
cacheHit: true,
|
|
346
|
+
sourceDir: bestEntry.sourceDir,
|
|
347
|
+
}
|
|
348
|
+
: null;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Resolves or populates a cached external template source directory.
|
|
352
|
+
*
|
|
353
|
+
* Returns `null` when caching is disabled. Cache misses populate a temporary
|
|
354
|
+
* directory first and then atomically move it into place; concurrent writers
|
|
355
|
+
* that lose the race reuse the completed marker/source pair.
|
|
356
|
+
*
|
|
357
|
+
* @param descriptor Namespace, key parts, and metadata for the cache entry.
|
|
358
|
+
* @param populateSourceDir Callback that writes the guarded source on a miss.
|
|
359
|
+
* @returns Cache resolution details, or `null` when caching is disabled.
|
|
360
|
+
*/
|
|
361
|
+
export async function resolveExternalTemplateSourceCache(descriptor, populateSourceDir) {
|
|
362
|
+
if (!isExternalTemplateCacheEnabled()) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
const cacheEntryPaths = getCacheEntryPaths(descriptor);
|
|
366
|
+
if (!cacheEntryPaths) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const { cacheKey, cacheRoot, entryDir, markerPath, namespaceDir, sourceDir } = cacheEntryPaths;
|
|
370
|
+
if (!(await ensurePrivateCacheDirectory(cacheRoot)) ||
|
|
371
|
+
!(await ensurePrivateCacheDirectory(namespaceDir))) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
if (await isReusableCacheEntry(entryDir, markerPath, sourceDir)) {
|
|
375
|
+
return {
|
|
376
|
+
cacheHit: true,
|
|
377
|
+
sourceDir,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
const temporaryEntryDir = path.join(namespaceDir, `.tmp-${cacheKey}-${process.pid}-${Date.now()}-${Math.random()
|
|
381
|
+
.toString(16)
|
|
382
|
+
.slice(2)}`);
|
|
383
|
+
const temporarySourceDir = path.join(temporaryEntryDir, 'source');
|
|
384
|
+
let populateFailed = false;
|
|
385
|
+
try {
|
|
386
|
+
await fsp.mkdir(temporarySourceDir, {
|
|
387
|
+
mode: PRIVATE_CACHE_DIRECTORY_MODE,
|
|
388
|
+
recursive: true,
|
|
389
|
+
});
|
|
390
|
+
if (process.platform !== 'win32') {
|
|
391
|
+
await fsp.chmod(temporaryEntryDir, PRIVATE_CACHE_DIRECTORY_MODE);
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
await populateSourceDir(temporarySourceDir);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
populateFailed = true;
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
await fsp.writeFile(path.join(temporaryEntryDir, CACHE_MARKER_FILE), `${JSON.stringify({
|
|
401
|
+
createdAt: new Date().toISOString(),
|
|
402
|
+
key: cacheKey,
|
|
403
|
+
metadata: sanitizeCacheMetadata(descriptor.metadata),
|
|
404
|
+
namespace: descriptor.namespace,
|
|
405
|
+
}, null, 2)}\n`, 'utf8');
|
|
406
|
+
await fsp.rename(temporaryEntryDir, entryDir);
|
|
407
|
+
return {
|
|
408
|
+
cacheHit: false,
|
|
409
|
+
sourceDir,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
await removeTemporaryCacheEntry(temporaryEntryDir);
|
|
414
|
+
if (populateFailed) {
|
|
415
|
+
if (CACHE_UNAVAILABLE_ERROR_CODES.has(getNodeErrorCode(error))) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
const errorCode = getNodeErrorCode(error);
|
|
421
|
+
if (CACHE_PUBLISH_RACE_ERROR_CODES.has(errorCode) &&
|
|
422
|
+
(await isReusableCacheEntry(entryDir, markerPath, sourceDir))) {
|
|
423
|
+
return {
|
|
424
|
+
cacheHit: true,
|
|
425
|
+
sourceDir,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (CACHE_PUBLISH_RACE_ERROR_CODES.has(errorCode) ||
|
|
429
|
+
CACHE_UNAVAILABLE_ERROR_CODES.has(errorCode)) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
}
|