@wp-typia/project-tools 0.22.4 → 0.22.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/runtime/ai-feature-capability.js +20 -0
  2. package/dist/runtime/cli-add-block.js +16 -11
  3. package/dist/runtime/cli-add-collision.js +213 -136
  4. package/dist/runtime/cli-add-help.js +1 -1
  5. package/dist/runtime/cli-add-kind-ids.d.ts +11 -0
  6. package/dist/runtime/cli-add-kind-ids.js +20 -0
  7. package/dist/runtime/cli-add-types.d.ts +2 -8
  8. package/dist/runtime/cli-add-types.js +1 -17
  9. package/dist/runtime/cli-add-workspace-ability-scaffold.d.ts +3 -1
  10. package/dist/runtime/cli-add-workspace-ability-scaffold.js +22 -5
  11. package/dist/runtime/cli-add-workspace-ability.d.ts +4 -0
  12. package/dist/runtime/cli-add-workspace-ability.js +5 -1
  13. package/dist/runtime/cli-add-workspace-admin-view-source.d.ts +7 -0
  14. package/dist/runtime/cli-add-workspace-admin-view-source.js +9 -10
  15. package/dist/runtime/cli-add-workspace-admin-view-types.d.ts +0 -2
  16. package/dist/runtime/cli-add-workspace-admin-view-types.js +0 -3
  17. package/dist/runtime/cli-add-workspace-ai-anchors.js +3 -24
  18. package/dist/runtime/cli-add-workspace-ai-scaffold.js +14 -6
  19. package/dist/runtime/cli-add-workspace-assets.js +7 -50
  20. package/dist/runtime/cli-add-workspace-rest-anchors.js +3 -24
  21. package/dist/runtime/cli-doctor-workspace-bindings.js +2 -3
  22. package/dist/runtime/cli-doctor-workspace-blocks.js +2 -3
  23. package/dist/runtime/cli-doctor-workspace-features.js +6 -11
  24. package/dist/runtime/cli-doctor-workspace-shared.d.ts +8 -0
  25. package/dist/runtime/cli-doctor-workspace-shared.js +10 -0
  26. package/dist/runtime/cli-help.js +1 -1
  27. package/dist/runtime/cli-init-apply.d.ts +15 -0
  28. package/dist/runtime/cli-init-apply.js +99 -0
  29. package/dist/runtime/cli-init-package-json.d.ts +19 -0
  30. package/dist/runtime/cli-init-package-json.js +191 -0
  31. package/dist/runtime/cli-init-plan.d.ts +39 -0
  32. package/dist/runtime/cli-init-plan.js +375 -0
  33. package/dist/runtime/cli-init-templates.d.ts +27 -0
  34. package/dist/runtime/cli-init-templates.js +244 -0
  35. package/dist/runtime/cli-init-types.d.ts +84 -0
  36. package/dist/runtime/cli-init-types.js +3 -0
  37. package/dist/runtime/cli-init.d.ts +4 -100
  38. package/dist/runtime/cli-init.js +6 -878
  39. package/dist/runtime/fs-async.d.ts +28 -0
  40. package/dist/runtime/fs-async.js +53 -0
  41. package/dist/runtime/package-managers.js +1 -1
  42. package/dist/runtime/php-utils.d.ts +16 -0
  43. package/dist/runtime/php-utils.js +321 -1
  44. package/dist/runtime/scaffold-apply-utils.js +10 -20
  45. package/dist/runtime/scaffold-bootstrap.js +6 -8
  46. package/dist/runtime/scaffold-compatibility.d.ts +15 -3
  47. package/dist/runtime/scaffold-compatibility.js +42 -11
  48. package/dist/runtime/scaffold-documents.js +12 -0
  49. package/dist/runtime/scaffold-package-manager-files.js +4 -3
  50. package/dist/runtime/string-case.d.ts +5 -0
  51. package/dist/runtime/string-case.js +52 -2
  52. package/dist/runtime/template-source-cache.d.ts +19 -0
  53. package/dist/runtime/template-source-cache.js +164 -28
  54. package/dist/runtime/template-source-external.d.ts +7 -0
  55. package/dist/runtime/template-source-external.js +22 -5
  56. package/dist/runtime/template-source-normalization.d.ts +1 -1
  57. package/dist/runtime/template-source-normalization.js +12 -12
  58. package/dist/runtime/template-source-remote.d.ts +14 -0
  59. package/dist/runtime/template-source-remote.js +91 -15
  60. package/dist/runtime/template-source.js +35 -25
  61. package/dist/runtime/typia-llm.js +7 -0
  62. package/dist/runtime/version-floor.js +8 -2
  63. package/dist/runtime/workspace-inventory.d.ts +16 -14
  64. package/dist/runtime/workspace-inventory.js +310 -239
  65. package/package.json +6 -1
@@ -5,7 +5,7 @@
5
5
  * metadata aligned when optional or required AI-capable features are added.
6
6
  */
7
7
  import { AI_FEATURE_DEFINITIONS, resolveAiFeatureCapabilityPlan, } from './ai-feature-capability.js';
8
- import { pickHigherVersionFloor } from './version-floor.js';
8
+ import { parseVersionFloorParts, pickHigherVersionFloor, } from './version-floor.js';
9
9
  /**
10
10
  * Baseline headers used by scaffold output before optional features are added.
11
11
  */
@@ -39,7 +39,7 @@ export const REQUIRED_WORKSPACE_ABILITY_COMPATIBILITY = [
39
39
  function pickHigherScaffoldVersionFloor(current, candidate) {
40
40
  return pickHigherVersionFloor(current, candidate) ?? current;
41
41
  }
42
- function pickHigherHeaderVersionFloor(policyValue, currentValue) {
42
+ function pickHigherHeaderVersionFloor(policyValue, currentValue, { headerName, onWarning, }) {
43
43
  const normalizedCurrentValue = currentValue.trim();
44
44
  if (!normalizedCurrentValue) {
45
45
  return policyValue;
@@ -47,10 +47,35 @@ function pickHigherHeaderVersionFloor(policyValue, currentValue) {
47
47
  try {
48
48
  return pickHigherScaffoldVersionFloor(policyValue, normalizedCurrentValue);
49
49
  }
50
- catch {
50
+ catch (error) {
51
+ const warning = [
52
+ `Invalid plugin header version floor for ${headerName}: "${normalizedCurrentValue}".`,
53
+ 'Expected dotted numeric segments such as "6.7" or "8.1.2".',
54
+ `Replacing it with compatibility policy value "${policyValue}".`,
55
+ ].join(' ');
56
+ if (!onWarning) {
57
+ throw new Error(warning, { cause: error });
58
+ }
59
+ onWarning(warning);
51
60
  return policyValue;
52
61
  }
53
62
  }
63
+ function assertPolicyVersionFloor(headerName, value) {
64
+ try {
65
+ parseVersionFloorParts(value);
66
+ }
67
+ catch (error) {
68
+ throw new Error([
69
+ `Invalid scaffold compatibility policy floor for ${headerName}: "${value}".`,
70
+ 'Expected dotted numeric segments such as "6.7" or "8.1.2".',
71
+ ].join(' '), { cause: error });
72
+ }
73
+ }
74
+ function assertPluginHeaderPolicyVersionFloors(pluginHeader) {
75
+ assertPolicyVersionFloor('Requires at least', pluginHeader.requiresAtLeast);
76
+ assertPolicyVersionFloor('Tested up to', pluginHeader.testedUpTo);
77
+ assertPolicyVersionFloor('Requires PHP', pluginHeader.requiresPhp);
78
+ }
54
79
  function formatRuntimeGate(feature) {
55
80
  return (feature.runtimeGates ?? []).map((gate) => `${feature.label}: ${gate.kind} ${gate.value}`);
56
81
  }
@@ -108,21 +133,27 @@ export function renderScaffoldCompatibilityConfig(policy, indent = '\t\t') {
108
133
  .map((line, index) => (index === 0 ? line : `${indent}${line}`))
109
134
  .join('\n');
110
135
  }
111
- function replacePluginHeaderVersionFloor(source, pattern, policyValue) {
136
+ function replacePluginHeaderVersionFloor(source, pattern, policyValue, headerName, options) {
112
137
  return source.replace(pattern, (_match, prefix, currentValue, lineEnding) => {
113
138
  const versionPrefix = prefix.endsWith(':') ? `${prefix} ` : prefix;
114
- return `${versionPrefix}${pickHigherHeaderVersionFloor(policyValue, currentValue)}${lineEnding}`;
139
+ return `${versionPrefix}${pickHigherHeaderVersionFloor(policyValue, currentValue, {
140
+ headerName,
141
+ onWarning: options.onWarning,
142
+ })}${lineEnding}`;
115
143
  });
116
144
  }
117
145
  /**
118
146
  * Patch a generated plugin bootstrap header without lowering custom floors.
119
147
  *
120
- * Preserves the original header line endings while replacing empty or invalid
121
- * version strings with the policy values.
148
+ * Preserves the original header line endings while replacing empty version
149
+ * strings with policy values. Malformed user-authored values are reported
150
+ * through `options.onWarning`; without a warning handler they throw instead of
151
+ * falling back silently.
122
152
  */
123
- export function updatePluginHeaderCompatibility(source, policy) {
153
+ export function updatePluginHeaderCompatibility(source, policy, options = {}) {
124
154
  const { pluginHeader } = policy;
125
- const nextSource = replacePluginHeaderVersionFloor(source, /(\* Requires at least:[^\S\r\n]*)([^\r\n]*)(\r?)/u, pluginHeader.requiresAtLeast);
126
- const nextSourceWithTestedUpTo = replacePluginHeaderVersionFloor(nextSource, /(\* Tested up to:[^\S\r\n]*)([^\r\n]*)(\r?)/u, pluginHeader.testedUpTo);
127
- return replacePluginHeaderVersionFloor(nextSourceWithTestedUpTo, /(\* Requires PHP:[^\S\r\n]*)([^\r\n]*)(\r?)/u, pluginHeader.requiresPhp);
155
+ assertPluginHeaderPolicyVersionFloors(pluginHeader);
156
+ const nextSource = replacePluginHeaderVersionFloor(source, /(\* Requires at least:[^\S\r\n]*)([^\r\n]*)(\r?)/u, pluginHeader.requiresAtLeast, 'Requires at least', options);
157
+ const nextSourceWithTestedUpTo = replacePluginHeaderVersionFloor(nextSource, /(\* Tested up to:[^\S\r\n]*)([^\r\n]*)(\r?)/u, pluginHeader.testedUpTo, 'Tested up to', options);
158
+ return replacePluginHeaderVersionFloor(nextSourceWithTestedUpTo, /(\* Requires PHP:[^\S\r\n]*)([^\r\n]*)(\r?)/u, pluginHeader.requiresPhp, 'Requires PHP', options);
128
159
  }
@@ -21,6 +21,17 @@ function formatReadmeTemplateIdentity(templateId) {
21
21
  }
22
22
  return [`- Template id: ${templateId}`, '- Type: custom or external scaffold'].join('\n');
23
23
  }
24
+ function getPackageManagerInstallGuidance(packageManager) {
25
+ if (packageManager !== 'npm') {
26
+ return '';
27
+ }
28
+ const installCommand = formatInstallCommand(packageManager);
29
+ return [
30
+ '',
31
+ `> npm note: the scaffold uses \`${installCommand}\` for the first install so npm does not spend the initial create flow in the audit resolver. Run \`npm audit\` separately when you want npm vulnerability output.`,
32
+ '> If npm prints React peer dependency noise from WordPress block-editor packages, validate with `npm run typecheck` and `npm run build` before changing WordPress package ranges.',
33
+ ].join('\n');
34
+ }
24
35
  /**
25
36
  * Builds the generated README markdown for one scaffolded project.
26
37
  *
@@ -99,6 +110,7 @@ ${formatReadmeTemplateIdentity(templateId)}
99
110
  ${formatInstallCommand(packageManager)}
100
111
  ${formatRunScript(packageManager, developmentScript)}
101
112
  \`\`\`
113
+ ${getPackageManagerInstallGuidance(packageManager)}
102
114
 
103
115
  ${getQuickStartWorkflowNote(packageManager, templateId, {
104
116
  compoundPersistenceEnabled,
@@ -1,8 +1,8 @@
1
- import fs from "node:fs";
2
1
  import { promises as fsp } from "node:fs";
3
2
  import { execSync } from "node:child_process";
4
3
  import path from "node:path";
5
4
  import { formatInstallCommand, getPackageManager, transformPackageManagerText, } from "./package-managers.js";
5
+ import { readOptionalUtf8File } from "./fs-async.js";
6
6
  const LOCKFILES = {
7
7
  bun: ["bun.lock", "bun.lockb"],
8
8
  npm: ["package-lock.json"],
@@ -33,11 +33,12 @@ export async function normalizePackageManagerFiles(targetDir, packageManagerId)
33
33
  */
34
34
  export async function normalizePackageJson(targetDir, packageManagerId) {
35
35
  const packageJsonPath = path.join(targetDir, "package.json");
36
- if (!fs.existsSync(packageJsonPath)) {
36
+ const packageJsonSource = await readOptionalUtf8File(packageJsonPath);
37
+ if (packageJsonSource === null) {
37
38
  return;
38
39
  }
39
40
  const packageManager = getPackageManager(packageManagerId);
40
- const packageJson = JSON.parse(await fsp.readFile(packageJsonPath, "utf8"));
41
+ const packageJson = JSON.parse(packageJsonSource);
41
42
  if (packageManagerId === "npm") {
42
43
  delete packageJson.packageManager;
43
44
  }
@@ -1,5 +1,10 @@
1
1
  /**
2
2
  * Normalize arbitrary text into a kebab-case identifier.
3
+ * Common acronym runs stay grouped, with a boundary before the next
4
+ * capitalized word.
5
+ * Ambiguous acronym+lowercase inputs like `URLslug` intentionally stay as one
6
+ * word because there is no PascalCase boundary marker before the lowercase
7
+ * suffix.
3
8
  *
4
9
  * @param input Raw text that may contain spaces, punctuation, or camelCase.
5
10
  * @returns A lowercase kebab-case string with collapsed separators.
@@ -1,15 +1,65 @@
1
+ const COMMON_ACRONYM_PREFIXES = [
2
+ 'HTML',
3
+ 'HTTP',
4
+ 'JSON',
5
+ 'REST',
6
+ 'UUID',
7
+ 'API',
8
+ 'CSS',
9
+ 'CTA',
10
+ 'DOM',
11
+ 'PHP',
12
+ 'SQL',
13
+ 'SVG',
14
+ 'URL',
15
+ 'XML',
16
+ 'ID',
17
+ 'JS',
18
+ 'UI',
19
+ 'WP',
20
+ ];
1
21
  function capitalizeSegment(segment) {
2
22
  return segment.charAt(0).toUpperCase() + segment.slice(1);
3
23
  }
24
+ function findCommonAcronymPrefix(segment) {
25
+ return COMMON_ACRONYM_PREFIXES.find((prefix) => segment.startsWith(prefix));
26
+ }
27
+ function splitKnownAcronymSegment(segment) {
28
+ const prefixes = [];
29
+ let remaining = segment;
30
+ while (remaining.length > 0) {
31
+ const prefix = findCommonAcronymPrefix(remaining);
32
+ if (!prefix) {
33
+ break;
34
+ }
35
+ const suffix = remaining.slice(prefix.length);
36
+ if (/^[A-Z][a-z]/.test(suffix)) {
37
+ return [...prefixes, prefix, suffix].join('-');
38
+ }
39
+ if (!findCommonAcronymPrefix(suffix)) {
40
+ break;
41
+ }
42
+ prefixes.push(prefix);
43
+ remaining = suffix;
44
+ }
45
+ return segment;
46
+ }
47
+ function splitAcronymBoundary(value) {
48
+ return value.replace(/[A-Z]{2,}[a-z]+/g, splitKnownAcronymSegment);
49
+ }
4
50
  /**
5
51
  * Normalize arbitrary text into a kebab-case identifier.
52
+ * Common acronym runs stay grouped, with a boundary before the next
53
+ * capitalized word.
54
+ * Ambiguous acronym+lowercase inputs like `URLslug` intentionally stay as one
55
+ * word because there is no PascalCase boundary marker before the lowercase
56
+ * suffix.
6
57
  *
7
58
  * @param input Raw text that may contain spaces, punctuation, or camelCase.
8
59
  * @returns A lowercase kebab-case string with collapsed separators.
9
60
  */
10
61
  export function toKebabCase(input) {
11
- return input
12
- .trim()
62
+ return splitAcronymBoundary(input.trim())
13
63
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
14
64
  .replace(/[^A-Za-z0-9]+/g, '-')
15
65
  .replace(/^-+|-+$/g, '')
@@ -14,6 +14,13 @@ export declare const EXTERNAL_TEMPLATE_CACHE_DIR_ENV = "WP_TYPIA_EXTERNAL_TEMPLA
14
14
  * Unset, empty, zero, negative, and non-numeric values keep pruning disabled.
15
15
  */
16
16
  export declare const EXTERNAL_TEMPLATE_CACHE_TTL_DAYS_ENV = "WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_TTL_DAYS";
17
+ /**
18
+ * Environment variable that overrides how often TTL pruning may scan the cache.
19
+ *
20
+ * Unset values use the default interval. Zero, negative, and non-numeric values
21
+ * disable scan throttling.
22
+ */
23
+ export declare const EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS_ENV = "WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS";
17
24
  /**
18
25
  * Serializable metadata recorded in the cache marker for diagnostics.
19
26
  */
@@ -72,10 +79,18 @@ export interface ExternalTemplateCachePruneOptions {
72
79
  * Environment object to inspect, defaulting to `process.env`.
73
80
  */
74
81
  env?: NodeJS.ProcessEnv;
82
+ /**
83
+ * Force a full prune scan even when the last-pruned marker is still fresh.
84
+ */
85
+ force?: boolean;
75
86
  /**
76
87
  * Clock override for deterministic tests.
77
88
  */
78
89
  now?: Date | number;
90
+ /**
91
+ * Minimum interval between full prune scans. Omit for the environment/default.
92
+ */
93
+ pruneIntervalMs?: number;
79
94
  /**
80
95
  * TTL override in days. When omitted, the TTL environment variable is used.
81
96
  */
@@ -101,6 +116,10 @@ export interface ExternalTemplateCachePruneResult {
101
116
  * Candidate directories skipped because they were malformed or unsafe.
102
117
  */
103
118
  skippedEntries: number;
119
+ /**
120
+ * Whether a recent last-pruned marker skipped the full cache directory scan.
121
+ */
122
+ skippedByThrottle: boolean;
104
123
  /**
105
124
  * Resolved TTL in milliseconds, or `null` when pruning was disabled.
106
125
  */
@@ -19,14 +19,29 @@ export const EXTERNAL_TEMPLATE_CACHE_DIR_ENV = 'WP_TYPIA_EXTERNAL_TEMPLATE_CACHE
19
19
  * Unset, empty, zero, negative, and non-numeric values keep pruning disabled.
20
20
  */
21
21
  export const EXTERNAL_TEMPLATE_CACHE_TTL_DAYS_ENV = 'WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_TTL_DAYS';
22
+ /**
23
+ * Environment variable that overrides how often TTL pruning may scan the cache.
24
+ *
25
+ * Unset values use the default interval. Zero, negative, and non-numeric values
26
+ * disable scan throttling.
27
+ */
28
+ export const EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS_ENV = 'WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS';
22
29
  /**
23
30
  * Marker file written after a cache entry is fully populated.
24
31
  */
25
32
  const CACHE_MARKER_FILE = 'wp-typia-template-cache.json';
33
+ /**
34
+ * Marker file written after a full TTL prune scan completes.
35
+ */
36
+ const CACHE_PRUNE_MARKER_FILE = 'wp-typia-template-cache-prune.json';
26
37
  /**
27
38
  * Milliseconds in one TTL day.
28
39
  */
29
40
  const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
41
+ /**
42
+ * Default minimum interval between full external template cache prune scans.
43
+ */
44
+ const DEFAULT_CACHE_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
30
45
  /**
31
46
  * Private directory mode used for cache roots and entries on POSIX platforms.
32
47
  */
@@ -119,6 +134,27 @@ function resolveExternalTemplateCacheTtlMs(options = {}) {
119
134
  const ttlMs = ttlDays * MILLISECONDS_PER_DAY;
120
135
  return Number.isFinite(ttlMs) ? ttlMs : null;
121
136
  }
137
+ function parseExternalTemplateCachePruneIntervalMs(value) {
138
+ if (typeof value !== 'string' && typeof value !== 'number') {
139
+ return null;
140
+ }
141
+ const intervalMs = typeof value === 'number' ? value : Number(value.trim());
142
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
143
+ return null;
144
+ }
145
+ return intervalMs;
146
+ }
147
+ function resolveExternalTemplateCachePruneIntervalMs(options = {}) {
148
+ if (options.pruneIntervalMs !== undefined) {
149
+ return parseExternalTemplateCachePruneIntervalMs(options.pruneIntervalMs);
150
+ }
151
+ const env = options.env ?? process.env;
152
+ const envValue = env[EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS_ENV];
153
+ if (envValue === undefined) {
154
+ return DEFAULT_CACHE_PRUNE_INTERVAL_MS;
155
+ }
156
+ return parseExternalTemplateCachePruneIntervalMs(envValue);
157
+ }
122
158
  /**
123
159
  * Creates a deterministic cache key from source identity and integrity parts.
124
160
  *
@@ -313,6 +349,14 @@ function parseCacheMarkerMetadata(markerText) {
313
349
  metadata,
314
350
  };
315
351
  }
352
+ async function readCacheEntryMarker(markerPath) {
353
+ try {
354
+ return parseCacheMarkerMetadata(await fsp.readFile(markerPath, 'utf8'));
355
+ }
356
+ catch {
357
+ return null;
358
+ }
359
+ }
316
360
  function cacheMetadataMatches(actual, expected) {
317
361
  return Object.entries(expected).every(([key, value]) => actual[key] === value);
318
362
  }
@@ -324,6 +368,19 @@ function getExternalTemplateCacheNowMs(now) {
324
368
  : Date.now();
325
369
  return Number.isFinite(nowMs) ? nowMs : Date.now();
326
370
  }
371
+ function isCacheEntryFreshForTtl(createdAtMs, nowMs, ttlMs) {
372
+ return ttlMs === null || createdAtMs >= nowMs - ttlMs;
373
+ }
374
+ async function getReusableCacheEntryMarker(entryDir, markerPath, sourceDir) {
375
+ if (!(await isReusableCacheEntry(entryDir, markerPath, sourceDir))) {
376
+ return null;
377
+ }
378
+ return readCacheEntryMarker(markerPath);
379
+ }
380
+ async function isReusableFreshCacheEntry(entryDir, markerPath, sourceDir, nowMs, ttlMs) {
381
+ const marker = await getReusableCacheEntryMarker(entryDir, markerPath, sourceDir);
382
+ return (marker !== null && isCacheEntryFreshForTtl(marker.createdAtMs, nowMs, ttlMs));
383
+ }
327
384
  function isPathInsideDirectory(directory, candidatePath) {
328
385
  const relativePath = path.relative(directory, candidatePath);
329
386
  return (relativePath.length > 0 &&
@@ -342,6 +399,74 @@ async function removeCacheEntryWithinRoot(cacheRoot, entryDir) {
342
399
  return false;
343
400
  }
344
401
  }
402
+ function getCachePruneMarkerPath(cacheRoot) {
403
+ return path.join(cacheRoot, CACHE_PRUNE_MARKER_FILE);
404
+ }
405
+ function parseCachePruneMarker(markerText) {
406
+ let marker;
407
+ try {
408
+ marker = JSON.parse(markerText);
409
+ }
410
+ catch {
411
+ return null;
412
+ }
413
+ if (typeof marker !== 'object' || marker === null || Array.isArray(marker)) {
414
+ return null;
415
+ }
416
+ const rawPrunedAt = marker.prunedAt;
417
+ const prunedAtMs = typeof rawPrunedAt === 'string' ? Date.parse(rawPrunedAt) : Number.NaN;
418
+ const rawPruneIntervalMs = marker
419
+ .pruneIntervalMs;
420
+ const rawTtlMs = marker.ttlMs;
421
+ if (typeof rawTtlMs !== 'number' || !Number.isFinite(rawTtlMs)) {
422
+ return null;
423
+ }
424
+ if (!Number.isFinite(prunedAtMs)) {
425
+ return null;
426
+ }
427
+ if (rawPruneIntervalMs !== null &&
428
+ (typeof rawPruneIntervalMs !== 'number' ||
429
+ !Number.isFinite(rawPruneIntervalMs))) {
430
+ return null;
431
+ }
432
+ return {
433
+ prunedAtMs,
434
+ pruneIntervalMs: rawPruneIntervalMs ?? null,
435
+ ttlMs: rawTtlMs,
436
+ };
437
+ }
438
+ async function shouldSkipExternalTemplateCachePrune({ cacheRoot, force, nowMs, pruneIntervalMs, ttlMs, }) {
439
+ if (force || pruneIntervalMs === null) {
440
+ return false;
441
+ }
442
+ let markerText;
443
+ try {
444
+ markerText = await fsp.readFile(getCachePruneMarkerPath(cacheRoot), 'utf8');
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ const marker = parseCachePruneMarker(markerText);
450
+ if (!marker ||
451
+ marker.ttlMs !== ttlMs ||
452
+ marker.pruneIntervalMs !== pruneIntervalMs) {
453
+ return false;
454
+ }
455
+ const elapsedMs = nowMs - marker.prunedAtMs;
456
+ return elapsedMs >= 0 && elapsedMs < pruneIntervalMs;
457
+ }
458
+ async function writeExternalTemplateCachePruneMarker({ cacheRoot, nowMs, pruneIntervalMs, ttlMs, }) {
459
+ try {
460
+ await fsp.writeFile(getCachePruneMarkerPath(cacheRoot), `${JSON.stringify({
461
+ prunedAt: new Date(nowMs).toISOString(),
462
+ pruneIntervalMs,
463
+ ttlMs,
464
+ }, null, 2)}\n`, 'utf8');
465
+ }
466
+ catch {
467
+ // Prune markers are an optimization; failing to write one keeps TTL safe.
468
+ }
469
+ }
345
470
  /**
346
471
  * Removes stale external template cache entries when a positive TTL is configured.
347
472
  *
@@ -359,16 +484,32 @@ export async function pruneExternalTemplateCache(options = {}) {
359
484
  env,
360
485
  ttlDays: options.ttlDays,
361
486
  });
487
+ const pruneIntervalMs = resolveExternalTemplateCachePruneIntervalMs({
488
+ env,
489
+ pruneIntervalMs: options.pruneIntervalMs,
490
+ });
362
491
  const result = {
363
492
  cacheRoot,
364
493
  prunedEntries: 0,
365
494
  scannedEntries: 0,
366
495
  skippedEntries: 0,
496
+ skippedByThrottle: false,
367
497
  ttlMs,
368
498
  };
369
499
  if (ttlMs === null || !(await isPrivateCacheDirectory(cacheRoot))) {
370
500
  return result;
371
501
  }
502
+ const nowMs = getExternalTemplateCacheNowMs(options.now);
503
+ if (await shouldSkipExternalTemplateCachePrune({
504
+ cacheRoot,
505
+ force: options.force,
506
+ nowMs,
507
+ pruneIntervalMs,
508
+ ttlMs,
509
+ })) {
510
+ result.skippedByThrottle = true;
511
+ return result;
512
+ }
372
513
  let namespaceEntries;
373
514
  try {
374
515
  namespaceEntries = await fsp.readdir(cacheRoot, { withFileTypes: true });
@@ -376,7 +517,7 @@ export async function pruneExternalTemplateCache(options = {}) {
376
517
  catch {
377
518
  return result;
378
519
  }
379
- const expiresBeforeMs = getExternalTemplateCacheNowMs(options.now) - ttlMs;
520
+ const expiresBeforeMs = nowMs - ttlMs;
380
521
  for (const namespaceEntry of namespaceEntries) {
381
522
  if (!namespaceEntry.isDirectory()) {
382
523
  continue;
@@ -410,19 +551,7 @@ export async function pruneExternalTemplateCache(options = {}) {
410
551
  }
411
552
  const markerPath = path.join(entryDir, CACHE_MARKER_FILE);
412
553
  const sourceDir = path.join(entryDir, 'source');
413
- if (!(await isReusableCacheEntry(entryDir, markerPath, sourceDir))) {
414
- result.skippedEntries += 1;
415
- continue;
416
- }
417
- let markerText;
418
- try {
419
- markerText = await fsp.readFile(markerPath, 'utf8');
420
- }
421
- catch {
422
- result.skippedEntries += 1;
423
- continue;
424
- }
425
- const marker = parseCacheMarkerMetadata(markerText);
554
+ const marker = await getReusableCacheEntryMarker(entryDir, markerPath, sourceDir);
426
555
  if (!marker) {
427
556
  result.skippedEntries += 1;
428
557
  continue;
@@ -437,6 +566,12 @@ export async function pruneExternalTemplateCache(options = {}) {
437
566
  }
438
567
  }
439
568
  }
569
+ await writeExternalTemplateCachePruneMarker({
570
+ cacheRoot,
571
+ nowMs,
572
+ pruneIntervalMs,
573
+ ttlMs,
574
+ });
440
575
  return result;
441
576
  }
442
577
  /**
@@ -462,6 +597,8 @@ export async function findReusableExternalTemplateSourceCache(descriptor) {
462
597
  !(await isPrivateCacheDirectory(namespaceDir))) {
463
598
  return null;
464
599
  }
600
+ const ttlMs = resolveExternalTemplateCacheTtlMs();
601
+ const nowMs = getExternalTemplateCacheNowMs(undefined);
465
602
  await pruneExternalTemplateCache();
466
603
  let entries;
467
604
  try {
@@ -478,18 +615,10 @@ export async function findReusableExternalTemplateSourceCache(descriptor) {
478
615
  const entryDir = path.join(namespaceDir, entry.name);
479
616
  const markerPath = path.join(entryDir, CACHE_MARKER_FILE);
480
617
  const sourceDir = path.join(entryDir, 'source');
481
- if (!(await isReusableCacheEntry(entryDir, markerPath, sourceDir))) {
482
- continue;
483
- }
484
- let markerText;
485
- try {
486
- markerText = await fsp.readFile(markerPath, 'utf8');
487
- }
488
- catch {
489
- continue;
490
- }
491
- const marker = parseCacheMarkerMetadata(markerText);
492
- if (!marker || !cacheMetadataMatches(marker.metadata, descriptor.metadata)) {
618
+ const marker = await getReusableCacheEntryMarker(entryDir, markerPath, sourceDir);
619
+ if (!marker ||
620
+ !isCacheEntryFreshForTtl(marker.createdAtMs, nowMs, ttlMs) ||
621
+ !cacheMetadataMatches(marker.metadata, descriptor.metadata)) {
493
622
  continue;
494
623
  }
495
624
  if (!bestEntry || marker.createdAtMs > bestEntry.createdAtMs) {
@@ -530,13 +659,20 @@ export async function resolveExternalTemplateSourceCache(descriptor, populateSou
530
659
  !(await ensurePrivateCacheDirectory(namespaceDir))) {
531
660
  return null;
532
661
  }
662
+ const ttlMs = resolveExternalTemplateCacheTtlMs();
663
+ const nowMs = getExternalTemplateCacheNowMs(undefined);
533
664
  await pruneExternalTemplateCache();
534
- if (await isReusableCacheEntry(entryDir, markerPath, sourceDir)) {
665
+ const existingMarker = await getReusableCacheEntryMarker(entryDir, markerPath, sourceDir);
666
+ if (existingMarker &&
667
+ isCacheEntryFreshForTtl(existingMarker.createdAtMs, nowMs, ttlMs)) {
535
668
  return {
536
669
  cacheHit: true,
537
670
  sourceDir,
538
671
  };
539
672
  }
673
+ if (existingMarker) {
674
+ await removeCacheEntryWithinRoot(cacheRoot, entryDir);
675
+ }
540
676
  const temporaryEntryDir = path.join(namespaceDir, `.tmp-${cacheKey}-${process.pid}-${Date.now()}-${Math.random()
541
677
  .toString(16)
542
678
  .slice(2)}`);
@@ -579,7 +715,7 @@ export async function resolveExternalTemplateSourceCache(descriptor, populateSou
579
715
  }
580
716
  const errorCode = getNodeErrorCode(error);
581
717
  if (CACHE_PUBLISH_RACE_ERROR_CODES.has(errorCode) &&
582
- (await isReusableCacheEntry(entryDir, markerPath, sourceDir))) {
718
+ (await isReusableFreshCacheEntry(entryDir, markerPath, sourceDir, nowMs, ttlMs))) {
583
719
  return {
584
720
  cacheHit: true,
585
721
  sourceDir,
@@ -11,6 +11,13 @@ export declare const EXTERNAL_TEMPLATE_TRUST_WARNING = "External template config
11
11
  * @returns The first matching entry path, or null when no supported entry exists.
12
12
  */
13
13
  export declare function getExternalTemplateEntry(sourceDir: string): string | null;
14
+ /**
15
+ * Search a source directory for a supported external template entry asynchronously.
16
+ *
17
+ * @param sourceDir Directory that may contain an external template config entry.
18
+ * @returns The first matching entry path, or null when no supported entry exists.
19
+ */
20
+ export declare function findExternalTemplateEntry(sourceDir: string): Promise<string | null>;
14
21
  /**
15
22
  * Load an official external template config and render its seed.
16
23
  *
@@ -1,5 +1,6 @@
1
1
  /// <reference path="./external-template-modules.d.ts" />
2
2
  import fs from 'node:fs';
3
+ import { promises as fsp } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { pathToFileURL } from 'node:url';
5
6
  import { assertExternalTemplateFileSize, getExternalTemplateConfigMaxBytes, withExternalTemplateTimeout, } from './external-template-guards.js';
@@ -7,6 +8,7 @@ import { isPlainObject } from './object-utils.js';
7
8
  import { toSegmentPascalCase } from './string-case.js';
8
9
  import { createManagedTempRoot } from './temp-roots.js';
9
10
  import { copyRawDirectory, copyRenderedDirectory } from './template-render.js';
11
+ import { pathExists } from './fs-async.js';
10
12
  /**
11
13
  * Candidate filenames for official external template config entrypoints.
12
14
  */
@@ -43,8 +45,23 @@ export function getExternalTemplateEntry(sourceDir) {
43
45
  }
44
46
  return null;
45
47
  }
48
+ /**
49
+ * Search a source directory for a supported external template entry asynchronously.
50
+ *
51
+ * @param sourceDir Directory that may contain an external template config entry.
52
+ * @returns The first matching entry path, or null when no supported entry exists.
53
+ */
54
+ export async function findExternalTemplateEntry(sourceDir) {
55
+ for (const filename of EXTERNAL_TEMPLATE_ENTRY_CANDIDATES) {
56
+ const candidate = path.join(sourceDir, filename);
57
+ if (await pathExists(candidate)) {
58
+ return candidate;
59
+ }
60
+ }
61
+ return null;
62
+ }
46
63
  async function loadExternalTemplateConfig(sourceDir) {
47
- const entryPath = getExternalTemplateEntry(sourceDir);
64
+ const entryPath = await findExternalTemplateEntry(sourceDir);
48
65
  if (!entryPath) {
49
66
  throw new Error(`No external template config entry found in ${sourceDir}.`);
50
67
  }
@@ -52,7 +69,8 @@ async function loadExternalTemplateConfig(sourceDir) {
52
69
  label: `External template config "${entryPath}"`,
53
70
  maxBytes: getExternalTemplateConfigMaxBytes(),
54
71
  });
55
- const moduleUrl = `${pathToFileURL(entryPath).href}?mtime=${fs.statSync(entryPath).mtimeMs}`;
72
+ const entryStats = await fsp.stat(entryPath);
73
+ const moduleUrl = `${pathToFileURL(entryPath).href}?mtime=${entryStats.mtimeMs}`;
56
74
  const loadedModule = (await withExternalTemplateTimeout(`loading external template config "${entryPath}"`, () => import(moduleUrl)));
57
75
  const loadedConfig = loadedModule.default ?? loadedModule;
58
76
  if (!isPlainObject(loadedConfig)) {
@@ -200,10 +218,9 @@ export async function renderCreateBlockExternalTemplate(sourceDir, context, requ
200
218
  if (typeof assetsPath === 'string' && assetsPath.trim().length > 0) {
201
219
  await copyRawDirectory(resolveSourceSubpath(sourceDir, assetsPath), path.join(tempRoot, 'assets'));
202
220
  }
221
+ const assetsDir = path.join(tempRoot, 'assets');
203
222
  return {
204
- assetsDir: fs.existsSync(path.join(tempRoot, 'assets'))
205
- ? path.join(tempRoot, 'assets')
206
- : undefined,
223
+ assetsDir: (await pathExists(assetsDir)) ? assetsDir : undefined,
207
224
  blockDir,
208
225
  cleanup,
209
226
  formatHint,
@@ -1,5 +1,5 @@
1
1
  export { renderCreateBlockExternalTemplate } from './template-source-external.js';
2
- export { getTemplateProjectType, getDefaultCategory, normalizeCreateBlockSubset, normalizeWpTypiaTemplateSeed, } from './template-source-remote.js';
2
+ export { getTemplateProjectType, getTemplateProjectTypeAsync, getDefaultCategory, getDefaultCategoryAsync, normalizeCreateBlockSubset, normalizeWpTypiaTemplateSeed, } from './template-source-remote.js';
3
3
  import type { TemplateSourceFormat, TemplateVariableContext } from './template-source-contracts.js';
4
4
  export declare function getTemplateVariableContext(variables: {
5
5
  [key: string]: string;