@wp-typia/project-tools 0.19.0 → 0.19.1

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.
@@ -2,6 +2,7 @@ import { getPrimaryDevelopmentScript } from './local-dev-presets.js';
2
2
  import { getCompoundExtensionWorkflowSection, getInitialCommitCommands, getInitialCommitNote, getOptionalOnboardingNote, getOptionalOnboardingSteps, getQuickStartWorkflowNote, getPhpRestExtensionPointsSection, getTemplateSourceOfTruthNote, } from './scaffold-onboarding.js';
3
3
  import { formatPackageExecCommand, formatInstallCommand, formatRunScript, } from './package-managers.js';
4
4
  import { getPackageVersions } from './package-versions.js';
5
+ import { getScaffoldTemplateVariableGroups } from './scaffold-template-variable-groups.js';
5
6
  /**
6
7
  * Builds the generated README markdown for one scaffolded project.
7
8
  *
@@ -12,26 +13,28 @@ import { getPackageVersions } from './package-versions.js';
12
13
  * @returns Markdown README content for the generated project root.
13
14
  */
14
15
  export function buildReadme(templateId, variables, packageManager, { withMigrationUi = false, withTestPreset = false, withWpEnv = false, } = {}) {
16
+ const variableGroups = getScaffoldTemplateVariableGroups(variables);
17
+ const compoundPersistenceEnabled = variableGroups.compound.enabled &&
18
+ variableGroups.compound.persistenceEnabled;
15
19
  const optionalOnboardingSteps = getOptionalOnboardingSteps(packageManager, templateId, {
16
- compoundPersistenceEnabled: variables.compoundPersistenceEnabled === 'true',
20
+ compoundPersistenceEnabled,
17
21
  });
18
22
  const initialCommitCommands = getInitialCommitCommands();
19
23
  const sourceOfTruthNote = getTemplateSourceOfTruthNote(templateId, {
20
- compoundPersistenceEnabled: variables.compoundPersistenceEnabled === 'true',
24
+ compoundPersistenceEnabled,
21
25
  });
22
- const compoundPersistenceEnabled = variables.compoundPersistenceEnabled === 'true';
23
- const publicPersistencePolicyNote = variables.isPublicPersistencePolicy === 'true'
26
+ const publicPersistencePolicyNote = variableGroups.persistence.enabled && variableGroups.persistence.auth.isPublic
24
27
  ? 'Public persistence writes use signed short-lived tokens, per-request ids, and coarse rate limiting by default. Add application-specific abuse controls before using the same pattern for high-value metrics or experiments.'
25
28
  : null;
26
- const alternateRenderTargetSection = variables.hasAlternateRenderTargets === 'true'
29
+ const alternateRenderTargetSection = variableGroups.alternateRenderTargets.enabled
27
30
  ? `## Alternate Render Targets\n\nThis scaffold keeps \`${templateId === 'compound' ? `src/blocks/${variables.slugKebabCase}/render.php` : 'src/render.php'}\` as the default web render boundary and also generates ${[
28
- variables.hasAlternateEmailRenderTarget === 'true'
31
+ variableGroups.alternateRenderTargets.hasEmail
29
32
  ? `\`${templateId === 'compound' ? `src/blocks/${variables.slugKebabCase}/render-email.php` : 'src/render-email.php'}\``
30
33
  : null,
31
- variables.hasAlternateMjmlRenderTarget === 'true'
34
+ variableGroups.alternateRenderTargets.hasMjml
32
35
  ? `\`${templateId === 'compound' ? `src/blocks/${variables.slugKebabCase}/render-mjml.php` : 'src/render-mjml.php'}\``
33
36
  : null,
34
- variables.hasAlternatePlainTextRenderTarget === 'true'
37
+ variableGroups.alternateRenderTargets.hasPlainText
35
38
  ? `\`${templateId === 'compound' ? `src/blocks/${variables.slugKebabCase}/render-text.php` : 'src/render-text.php'}\``
36
39
  : null,
37
40
  ]
@@ -0,0 +1,154 @@
1
+ export declare const SCAFFOLD_TEMPLATE_VARIABLE_GROUPS: unique symbol;
2
+ export type ScaffoldTemplateFamily = "basic" | "interactivity" | "persistence" | "compound" | "query-loop" | "external";
3
+ export interface ScaffoldSharedTemplateVariableGroup {
4
+ author: string;
5
+ blockMetadataVersion: string;
6
+ category: string;
7
+ cssClassName: string;
8
+ description: string;
9
+ descriptionJson: string;
10
+ frontendCssClassName: string;
11
+ icon: string;
12
+ keyword: string;
13
+ namespace: string;
14
+ pascalCase: string;
15
+ phpPrefix: string;
16
+ phpPrefixUpper: string;
17
+ slug: string;
18
+ slugCamelCase: string;
19
+ slugKebabCase: string;
20
+ slugSnakeCase: string;
21
+ textDomain: string;
22
+ title: string;
23
+ titleCase: string;
24
+ titleJson: string;
25
+ versions: {
26
+ apiClient: string;
27
+ blockRuntime: string;
28
+ blockTypes: string;
29
+ projectTools: string;
30
+ rest: string;
31
+ };
32
+ }
33
+ export interface ScaffoldAlternateRenderTargetVariableGroup {
34
+ csv: string;
35
+ enabled: boolean;
36
+ hasEmail: boolean;
37
+ hasMjml: boolean;
38
+ hasPlainText: boolean;
39
+ json: string;
40
+ targets: readonly string[];
41
+ }
42
+ export interface DisabledScaffoldCompoundVariableGroup {
43
+ enabled: false;
44
+ persistenceEnabled: false;
45
+ }
46
+ export interface EnabledScaffoldCompoundVariableGroup {
47
+ child: {
48
+ category: string;
49
+ cssClassName: string;
50
+ icon: string;
51
+ title: string;
52
+ titleJson: string;
53
+ };
54
+ enabled: true;
55
+ innerBlocks: {
56
+ description: string;
57
+ directInsert: boolean;
58
+ label: string;
59
+ orientation: "" | "horizontal" | "vertical";
60
+ orientationExpression: string;
61
+ preset: string;
62
+ templateLockExpression: string;
63
+ };
64
+ persistenceEnabled: boolean;
65
+ }
66
+ export type ScaffoldCompoundVariableGroup = DisabledScaffoldCompoundVariableGroup | EnabledScaffoldCompoundVariableGroup;
67
+ export interface DisabledScaffoldPersistenceVariableGroup {
68
+ enabled: false;
69
+ scope: "none";
70
+ }
71
+ export interface EnabledScaffoldPersistenceVariableGroup {
72
+ auth: {
73
+ bootstrapCredentialDeclarations: string;
74
+ descriptionJson: string;
75
+ intent: "authenticated" | "public-write-protected";
76
+ isAuthenticated: boolean;
77
+ isPublic: boolean;
78
+ mechanism: "public-signed-token" | "rest-nonce";
79
+ mode: "authenticated-rest-nonce" | "public-signed-token";
80
+ publicWriteRequestIdDeclaration: string;
81
+ };
82
+ dataStorageMode: "custom-table" | "post-meta";
83
+ enabled: true;
84
+ policy: "authenticated" | "public";
85
+ scope: "compound-parent" | "single";
86
+ }
87
+ export type ScaffoldPersistenceVariableGroup = DisabledScaffoldPersistenceVariableGroup | EnabledScaffoldPersistenceVariableGroup;
88
+ export interface DisabledScaffoldQueryLoopVariableGroup {
89
+ enabled: false;
90
+ }
91
+ export interface EnabledScaffoldQueryLoopVariableGroup {
92
+ allowedControls: readonly string[];
93
+ allowedControlsJson: string;
94
+ enabled: true;
95
+ postType: string;
96
+ postTypeJson: string;
97
+ variationNamespace: string;
98
+ variationNamespaceJson: string;
99
+ }
100
+ export type ScaffoldQueryLoopVariableGroup = DisabledScaffoldQueryLoopVariableGroup | EnabledScaffoldQueryLoopVariableGroup;
101
+ interface ScaffoldTemplateVariableGroupsBase {
102
+ alternateRenderTargets: ScaffoldAlternateRenderTargetVariableGroup;
103
+ shared: ScaffoldSharedTemplateVariableGroup;
104
+ template: {
105
+ description: string;
106
+ };
107
+ }
108
+ type DisabledTemplateFamilyGroups<TFamily extends "basic" | "interactivity" | "external"> = ScaffoldTemplateVariableGroupsBase & {
109
+ compound: DisabledScaffoldCompoundVariableGroup;
110
+ persistence: DisabledScaffoldPersistenceVariableGroup;
111
+ queryLoop: DisabledScaffoldQueryLoopVariableGroup;
112
+ templateFamily: TFamily;
113
+ };
114
+ export type BasicScaffoldTemplateVariableGroups = DisabledTemplateFamilyGroups<"basic">;
115
+ export type InteractivityScaffoldTemplateVariableGroups = DisabledTemplateFamilyGroups<"interactivity">;
116
+ export interface PersistenceScaffoldTemplateVariableGroups extends ScaffoldTemplateVariableGroupsBase {
117
+ compound: DisabledScaffoldCompoundVariableGroup;
118
+ persistence: EnabledScaffoldPersistenceVariableGroup & {
119
+ scope: "single";
120
+ };
121
+ queryLoop: DisabledScaffoldQueryLoopVariableGroup;
122
+ templateFamily: "persistence";
123
+ }
124
+ export interface CompoundScaffoldTemplateVariableGroups extends ScaffoldTemplateVariableGroupsBase {
125
+ compound: EnabledScaffoldCompoundVariableGroup;
126
+ persistence: DisabledScaffoldPersistenceVariableGroup | (EnabledScaffoldPersistenceVariableGroup & {
127
+ scope: "compound-parent";
128
+ });
129
+ queryLoop: DisabledScaffoldQueryLoopVariableGroup;
130
+ templateFamily: "compound";
131
+ }
132
+ export interface QueryLoopScaffoldTemplateVariableGroups extends ScaffoldTemplateVariableGroupsBase {
133
+ compound: DisabledScaffoldCompoundVariableGroup;
134
+ persistence: DisabledScaffoldPersistenceVariableGroup;
135
+ queryLoop: EnabledScaffoldQueryLoopVariableGroup;
136
+ templateFamily: "query-loop";
137
+ }
138
+ export type ExternalScaffoldTemplateVariableGroups = DisabledTemplateFamilyGroups<"external">;
139
+ export type ScaffoldTemplateVariableGroups = BasicScaffoldTemplateVariableGroups | InteractivityScaffoldTemplateVariableGroups | PersistenceScaffoldTemplateVariableGroups | CompoundScaffoldTemplateVariableGroups | QueryLoopScaffoldTemplateVariableGroups | ExternalScaffoldTemplateVariableGroups;
140
+ export interface ScaffoldTemplateVariableGroupsCarrier {
141
+ readonly [SCAFFOLD_TEMPLATE_VARIABLE_GROUPS]: ScaffoldTemplateVariableGroups;
142
+ }
143
+ export type CompoundScaffoldTemplateVariablesLike = ScaffoldTemplateVariableGroupsCarrier & {
144
+ slugKebabCase: string;
145
+ };
146
+ export type PersistenceScaffoldTemplateVariablesLike = ScaffoldTemplateVariableGroupsCarrier & {
147
+ namespace: string;
148
+ pascalCase: string;
149
+ slugKebabCase: string;
150
+ title: string;
151
+ };
152
+ export declare function attachScaffoldTemplateVariableGroups<TVariables extends Record<string, string>>(variables: TVariables, groups: ScaffoldTemplateVariableGroups): TVariables & ScaffoldTemplateVariableGroupsCarrier;
153
+ export declare function getScaffoldTemplateVariableGroups(variables: ScaffoldTemplateVariableGroupsCarrier): ScaffoldTemplateVariableGroups;
154
+ export {};
@@ -0,0 +1,13 @@
1
+ export const SCAFFOLD_TEMPLATE_VARIABLE_GROUPS = Symbol("wp-typia.scaffold-template-variable-groups");
2
+ export function attachScaffoldTemplateVariableGroups(variables, groups) {
3
+ Object.defineProperty(variables, SCAFFOLD_TEMPLATE_VARIABLE_GROUPS, {
4
+ configurable: false,
5
+ enumerable: false,
6
+ value: groups,
7
+ writable: false,
8
+ });
9
+ return variables;
10
+ }
11
+ export function getScaffoldTemplateVariableGroups(variables) {
12
+ return variables[SCAFFOLD_TEMPLATE_VARIABLE_GROUPS];
13
+ }
@@ -5,6 +5,7 @@ import { BUILTIN_BLOCK_METADATA_VERSION, COMPOUND_CHILD_BLOCK_METADATA_DEFAULTS,
5
5
  import { DEFAULT_COMPOUND_INNER_BLOCKS_PRESET_ID, getCompoundInnerBlocksPresetDefinition, } from './compound-inner-blocks.js';
6
6
  import { getTemplateById, isBuiltInTemplateId, } from './template-registry.js';
7
7
  import { toPascalCase, toSnakeCase, } from './string-case.js';
8
+ import { attachScaffoldTemplateVariableGroups } from "./scaffold-template-variable-groups.js";
8
9
  /**
9
10
  * Build the normalized template variables used by scaffold rendering.
10
11
  *
@@ -57,7 +58,7 @@ export function getTemplateVariables(templateId, answers) {
57
58
  const persistencePolicy = templateId === 'persistence' || compoundPersistenceEnabled
58
59
  ? answers.persistencePolicy ?? 'authenticated'
59
60
  : 'authenticated';
60
- return {
61
+ const flatVariables = {
61
62
  alternateRenderTargetsCsv: '',
62
63
  alternateRenderTargetsJson: '[]',
63
64
  apiClientPackageVersion,
@@ -136,4 +137,60 @@ export function getTemplateVariables(templateId, answers) {
136
137
  titleCase: pascalCase,
137
138
  persistencePolicy,
138
139
  };
140
+ return attachScaffoldTemplateVariableGroups(flatVariables, {
141
+ alternateRenderTargets: {
142
+ csv: '',
143
+ enabled: false,
144
+ hasEmail: false,
145
+ hasMjml: false,
146
+ hasPlainText: false,
147
+ json: '[]',
148
+ targets: [],
149
+ },
150
+ compound: {
151
+ enabled: false,
152
+ persistenceEnabled: false,
153
+ },
154
+ persistence: {
155
+ enabled: false,
156
+ scope: 'none',
157
+ },
158
+ queryLoop: {
159
+ enabled: false,
160
+ },
161
+ shared: {
162
+ author: answers.author.trim(),
163
+ blockMetadataVersion: BUILTIN_BLOCK_METADATA_VERSION,
164
+ category: metadataDefaults?.category ?? template?.defaultCategory ?? 'widgets',
165
+ cssClassName,
166
+ description,
167
+ descriptionJson: JSON.stringify(description),
168
+ frontendCssClassName: buildFrontendCssClassName(cssClassName),
169
+ icon: metadataDefaults?.icon ?? 'smiley',
170
+ keyword: slug.replace(/-/g, ' '),
171
+ namespace,
172
+ pascalCase,
173
+ phpPrefix,
174
+ phpPrefixUpper,
175
+ slug,
176
+ slugCamelCase: pascalCase.charAt(0).toLowerCase() + pascalCase.slice(1),
177
+ slugKebabCase: slug,
178
+ slugSnakeCase,
179
+ textDomain,
180
+ title,
181
+ titleCase: pascalCase,
182
+ titleJson: JSON.stringify(title),
183
+ versions: {
184
+ apiClient: apiClientPackageVersion,
185
+ blockRuntime: blockRuntimePackageVersion,
186
+ blockTypes: blockTypesPackageVersion,
187
+ projectTools: projectToolsPackageVersion,
188
+ rest: restPackageVersion,
189
+ },
190
+ },
191
+ template: {
192
+ description: template?.description ?? 'External scaffold template variables',
193
+ },
194
+ templateFamily: 'external',
195
+ });
139
196
  }
@@ -1,4 +1,5 @@
1
1
  import type { PackageManagerId } from "./package-managers.js";
2
+ import type { ScaffoldTemplateVariableGroupsCarrier } from "./scaffold-template-variable-groups.js";
2
3
  /**
3
4
  * User-facing scaffold answers before template rendering.
4
5
  *
@@ -29,7 +30,7 @@ export type PersistencePolicy = (typeof PERSISTENCE_POLICIES)[number];
29
30
  /**
30
31
  * Normalized template variables shared by built-in and remote scaffold flows.
31
32
  */
32
- export interface ScaffoldTemplateVariables extends Record<string, string> {
33
+ export interface FlatScaffoldTemplateVariables extends Record<string, string> {
33
34
  alternateRenderTargetsCsv: string;
34
35
  alternateRenderTargetsJson: string;
35
36
  apiClientPackageVersion: string;
@@ -94,6 +95,8 @@ export interface ScaffoldTemplateVariables extends Record<string, string> {
94
95
  titleCase: string;
95
96
  persistencePolicy: PersistencePolicy;
96
97
  }
98
+ export interface ScaffoldTemplateVariables extends FlatScaffoldTemplateVariables, ScaffoldTemplateVariableGroupsCarrier {
99
+ }
97
100
  /**
98
101
  * Resolve scaffold template input from either built-in template ids or custom
99
102
  * template identifiers such as local paths, GitHub refs, and npm packages.
@@ -176,6 +179,7 @@ export interface ScaffoldProjectResult {
176
179
  export { buildBlockCssClassName } from "./scaffold-identifiers.js";
177
180
  export { collectScaffoldAnswers, detectAuthor, getDefaultAnswers, resolvePackageManagerId, resolveTemplateId, } from "./scaffold-answer-resolution.js";
178
181
  export { getTemplateVariables } from "./scaffold-template-variables.js";
182
+ export { getScaffoldTemplateVariableGroups, type BasicScaffoldTemplateVariableGroups, type CompoundScaffoldTemplateVariableGroups, type ExternalScaffoldTemplateVariableGroups, type InteractivityScaffoldTemplateVariableGroups, type PersistenceScaffoldTemplateVariableGroups, type QueryLoopScaffoldTemplateVariableGroups, type ScaffoldTemplateFamily, type ScaffoldTemplateVariableGroups, type ScaffoldTemplateVariableGroupsCarrier, } from "./scaffold-template-variable-groups.js";
179
183
  export declare function isDataStorageMode(value: string): value is DataStorageMode;
180
184
  export declare function isPersistencePolicy(value: string): value is PersistencePolicy;
181
185
  export declare function scaffoldProject({ projectDir, templateId, answers, alternateRenderTargets, dataStorageMode, persistencePolicy, packageManager, externalLayerId, externalLayerSource, externalLayerSourceLabel, repositoryReference, cwd, allowExistingDir, noInstall, installDependencies, onProgress, variant, withMigrationUi, withTestPreset, withWpEnv, }: ScaffoldProjectOptions): Promise<ScaffoldProjectResult>;
@@ -12,6 +12,7 @@ import { OFFICIAL_WORKSPACE_TEMPLATE_PACKAGE, isBuiltInTemplateId, } from "./tem
12
12
  import { resolveTemplateSource } from "./template-source.js";
13
13
  import { BlockGeneratorService, } from "./block-generator-service.js";
14
14
  import { getTemplateVariables } from "./scaffold-template-variables.js";
15
+ import { getScaffoldTemplateVariableGroups } from "./scaffold-template-variable-groups.js";
15
16
  import { assertExternalLayerCompositionOptions, } from "./cli-validation.js";
16
17
  const WORKSPACE_TEMPLATE_ALIAS = "workspace";
17
18
  export const DATA_STORAGE_MODES = ["post-meta", "custom-table"];
@@ -19,6 +20,7 @@ export const PERSISTENCE_POLICIES = ["authenticated", "public"];
19
20
  export { buildBlockCssClassName } from "./scaffold-identifiers.js";
20
21
  export { collectScaffoldAnswers, detectAuthor, getDefaultAnswers, resolvePackageManagerId, resolveTemplateId, } from "./scaffold-answer-resolution.js";
21
22
  export { getTemplateVariables } from "./scaffold-template-variables.js";
23
+ export { getScaffoldTemplateVariableGroups, } from "./scaffold-template-variable-groups.js";
22
24
  export function isDataStorageMode(value) {
23
25
  return DATA_STORAGE_MODES.includes(value);
24
26
  }
@@ -164,8 +166,10 @@ export async function scaffoldProject({ projectDir, templateId, answers, alterna
164
166
  await fsp.writeFile(gitignorePath, mergeTextLines(buildGitignore(), existingGitignore), "utf8");
165
167
  await normalizePackageJson(projectDir, resolvedPackageManager);
166
168
  if (isBuiltInTemplate) {
169
+ const variableGroups = getScaffoldTemplateVariableGroups(variables);
167
170
  await applyGeneratedProjectDxPackageJson({
168
- compoundPersistenceEnabled: variables.compoundPersistenceEnabled === "true",
171
+ compoundPersistenceEnabled: variableGroups.compound.enabled &&
172
+ variableGroups.compound.persistenceEnabled,
169
173
  packageManager: resolvedPackageManager,
170
174
  projectDir,
171
175
  templateId: resolvedTemplateId,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Required prefix for managed wp-typia temporary directories.
3
+ */
4
+ export declare const WP_TYPIA_TEMP_ROOT_PREFIX = "wp-typia-";
5
+ /**
6
+ * Default age threshold for pruning stale wp-typia temp roots.
7
+ */
8
+ export declare const STALE_TEMP_ROOT_MAX_AGE_MS: number;
9
+ type TempRootOptions = {
10
+ maxAgeMs?: number;
11
+ now?: number;
12
+ tmpDir?: string;
13
+ };
14
+ /**
15
+ * Remove a managed temp root and stop tracking it for process-level cleanup.
16
+ *
17
+ * @param tempRoot Absolute temporary directory path to remove.
18
+ */
19
+ export declare function cleanupManagedTempRoot(tempRoot: string): Promise<void>;
20
+ /**
21
+ * Remove stale `wp-typia-*` temp directories from the target temp root.
22
+ *
23
+ * @param options Optional temp directory, age threshold, and clock override.
24
+ * @returns Absolute temp-root paths removed during the cleanup pass.
25
+ */
26
+ export declare function cleanupStaleTempRoots({ maxAgeMs, now, tmpDir, }?: TempRootOptions): Promise<string[]>;
27
+ /**
28
+ * Create a managed wp-typia temp root and install process cleanup handlers.
29
+ *
30
+ * @param prefix Temp directory prefix. Must start with `wp-typia-`.
31
+ * @param options Optional temp directory override.
32
+ * @returns The created temp-root path plus an async cleanup helper.
33
+ */
34
+ export declare function createManagedTempRoot(prefix: string, options?: Pick<TempRootOptions, "tmpDir">): Promise<{
35
+ cleanup: () => Promise<void>;
36
+ path: string;
37
+ }>;
38
+ /**
39
+ * Snapshot the currently tracked temp roots for diagnostics and tests.
40
+ *
41
+ * @returns Absolute paths for temp roots currently registered for cleanup.
42
+ */
43
+ export declare function getTrackedTempRoots(): string[];
44
+ export {};
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /**
6
+ * Required prefix for managed wp-typia temporary directories.
7
+ */
8
+ export const WP_TYPIA_TEMP_ROOT_PREFIX = "wp-typia-";
9
+ /**
10
+ * Default age threshold for pruning stale wp-typia temp roots.
11
+ */
12
+ export const STALE_TEMP_ROOT_MAX_AGE_MS = 1000 * 60 * 60 * 24;
13
+ const trackedTempRoots = new Set();
14
+ let cleanupHandlersInstalled = false;
15
+ let staleCleanupRan = false;
16
+ let signalCleanupInProgress = false;
17
+ function getTempDir(tmpDir) {
18
+ return tmpDir ?? os.tmpdir();
19
+ }
20
+ function cleanupTrackedTempRootsSync() {
21
+ for (const tempRoot of [...trackedTempRoots]) {
22
+ trackedTempRoots.delete(tempRoot);
23
+ try {
24
+ fs.rmSync(tempRoot, { force: true, recursive: true });
25
+ }
26
+ catch { }
27
+ }
28
+ }
29
+ function installCleanupHandlers() {
30
+ if (cleanupHandlersInstalled) {
31
+ return;
32
+ }
33
+ cleanupHandlersInstalled = true;
34
+ process.on("exit", cleanupTrackedTempRootsSync);
35
+ for (const [signal, exitCode] of [
36
+ ["SIGHUP", 129],
37
+ ["SIGINT", 130],
38
+ ["SIGTERM", 143],
39
+ ]) {
40
+ process.once(signal, () => {
41
+ if (signalCleanupInProgress) {
42
+ return;
43
+ }
44
+ signalCleanupInProgress = true;
45
+ cleanupTrackedTempRootsSync();
46
+ process.exitCode = exitCode;
47
+ process.exit();
48
+ });
49
+ }
50
+ }
51
+ /**
52
+ * Remove a managed temp root and stop tracking it for process-level cleanup.
53
+ *
54
+ * @param tempRoot Absolute temporary directory path to remove.
55
+ */
56
+ export async function cleanupManagedTempRoot(tempRoot) {
57
+ trackedTempRoots.delete(tempRoot);
58
+ await fsp.rm(tempRoot, { force: true, recursive: true });
59
+ }
60
+ /**
61
+ * Remove stale `wp-typia-*` temp directories from the target temp root.
62
+ *
63
+ * @param options Optional temp directory, age threshold, and clock override.
64
+ * @returns Absolute temp-root paths removed during the cleanup pass.
65
+ */
66
+ export async function cleanupStaleTempRoots({ maxAgeMs = STALE_TEMP_ROOT_MAX_AGE_MS, now = Date.now(), tmpDir, } = {}) {
67
+ const resolvedTmpDir = getTempDir(tmpDir);
68
+ const removedRoots = [];
69
+ const entries = await fsp.readdir(resolvedTmpDir, { withFileTypes: true });
70
+ for (const entry of entries) {
71
+ if (!entry.isDirectory() ||
72
+ !entry.name.startsWith(WP_TYPIA_TEMP_ROOT_PREFIX)) {
73
+ continue;
74
+ }
75
+ const tempRoot = path.join(resolvedTmpDir, entry.name);
76
+ if (trackedTempRoots.has(tempRoot)) {
77
+ continue;
78
+ }
79
+ let stats;
80
+ try {
81
+ stats = await fsp.stat(tempRoot);
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ if (now - stats.mtimeMs < maxAgeMs) {
87
+ continue;
88
+ }
89
+ try {
90
+ await fsp.rm(tempRoot, { force: true, recursive: true });
91
+ removedRoots.push(tempRoot);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ }
97
+ return removedRoots;
98
+ }
99
+ /**
100
+ * Create a managed wp-typia temp root and install process cleanup handlers.
101
+ *
102
+ * @param prefix Temp directory prefix. Must start with `wp-typia-`.
103
+ * @param options Optional temp directory override.
104
+ * @returns The created temp-root path plus an async cleanup helper.
105
+ */
106
+ export async function createManagedTempRoot(prefix, options = {}) {
107
+ if (!prefix.startsWith(WP_TYPIA_TEMP_ROOT_PREFIX)) {
108
+ throw new Error(`Managed wp-typia temp roots must use the "${WP_TYPIA_TEMP_ROOT_PREFIX}" prefix.`);
109
+ }
110
+ installCleanupHandlers();
111
+ if (!staleCleanupRan) {
112
+ staleCleanupRan = true;
113
+ await cleanupStaleTempRoots({ tmpDir: options.tmpDir });
114
+ }
115
+ const tempRoot = await fsp.mkdtemp(path.join(getTempDir(options.tmpDir), prefix));
116
+ trackedTempRoots.add(tempRoot);
117
+ return {
118
+ cleanup: async () => cleanupManagedTempRoot(tempRoot),
119
+ path: tempRoot,
120
+ };
121
+ }
122
+ /**
123
+ * Snapshot the currently tracked temp roots for diagnostics and tests.
124
+ *
125
+ * @returns Absolute paths for temp roots currently registered for cleanup.
126
+ */
127
+ export function getTrackedTempRoots() {
128
+ return [...trackedTempRoots];
129
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
  import { promises as fsp } from "node:fs";
4
+ import { createManagedTempRoot } from "./temp-roots.js";
5
5
  import { getTemplateById, SHARED_BASE_TEMPLATE_ROOT, SHARED_COMPOUND_TEMPLATE_ROOT, SHARED_MIGRATION_UI_TEMPLATE_ROOT, SHARED_PERSISTENCE_TEMPLATE_ROOT, SHARED_PRESET_TEMPLATE_ROOT, SHARED_REST_HELPER_TEMPLATE_ROOT, SHARED_WORKSPACE_TEMPLATE_ROOT, } from "./template-registry.js";
6
6
  const BUILT_IN_SHARED_TEMPLATE_LAYERS = Object.freeze([
7
7
  {
@@ -152,7 +152,7 @@ function resolveMaterializedTemplateLayerDirs(templateId, layerDirs) {
152
152
  async function materializeBuiltInTemplateSource(templateId, layerDirs) {
153
153
  const template = getTemplateById(templateId);
154
154
  const materializedLayerDirs = resolveMaterializedTemplateLayerDirs(templateId, layerDirs);
155
- const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "wp-typia-template-"));
155
+ const { path: tempRoot, cleanup } = await createManagedTempRoot("wp-typia-template-");
156
156
  const templateDir = path.join(tempRoot, templateId);
157
157
  try {
158
158
  await fsp.mkdir(templateDir, { recursive: true });
@@ -164,7 +164,7 @@ async function materializeBuiltInTemplateSource(templateId, layerDirs) {
164
164
  }
165
165
  }
166
166
  catch (error) {
167
- await fsp.rm(tempRoot, { force: true, recursive: true });
167
+ await cleanup();
168
168
  throw error;
169
169
  }
170
170
  return {
@@ -174,9 +174,7 @@ async function materializeBuiltInTemplateSource(templateId, layerDirs) {
174
174
  features: template.features,
175
175
  format: "wp-typia",
176
176
  templateDir,
177
- cleanup: async () => {
178
- await fsp.rm(tempRoot, { force: true, recursive: true });
179
- },
177
+ cleanup,
180
178
  };
181
179
  }
182
180
  export async function resolveBuiltInTemplateSourceFromLayerDirs(templateId, layerDirs) {
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Resolve the canonical `@wp-typia/project-tools` package root.
3
+ *
4
+ * When `WP_TYPIA_PROJECT_TOOLS_PACKAGE_ROOT` is set, the override is only
5
+ * accepted if it points at a readable package manifest whose `name` matches
6
+ * `@wp-typia/project-tools`. Invalid or stale overrides are ignored and normal
7
+ * upward package-root discovery continues.
8
+ */
1
9
  export declare function resolvePackageRoot(startDir: string): string;
2
10
  export declare const PROJECT_TOOLS_PACKAGE_ROOT: string;
3
11
  export declare const TEMPLATE_ROOT: string;
@@ -3,14 +3,47 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { getBuiltInTemplateMetadataDefaults } from "./template-defaults.js";
5
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const PROJECT_TOOLS_PACKAGE_ROOT_ENV = "WP_TYPIA_PROJECT_TOOLS_PACKAGE_ROOT";
7
+ const PROJECT_TOOLS_PACKAGE_NAME = "@wp-typia/project-tools";
8
+ function resolveValidProjectToolsPackageRoot(candidateRoot) {
9
+ const packageJsonPath = path.join(candidateRoot, "package.json");
10
+ if (!fs.existsSync(packageJsonPath)) {
11
+ return undefined;
12
+ }
13
+ try {
14
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
15
+ return packageJson.name === PROJECT_TOOLS_PACKAGE_NAME
16
+ ? candidateRoot
17
+ : undefined;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ /**
24
+ * Resolve the canonical `@wp-typia/project-tools` package root.
25
+ *
26
+ * When `WP_TYPIA_PROJECT_TOOLS_PACKAGE_ROOT` is set, the override is only
27
+ * accepted if it points at a readable package manifest whose `name` matches
28
+ * `@wp-typia/project-tools`. Invalid or stale overrides are ignored and normal
29
+ * upward package-root discovery continues.
30
+ */
6
31
  export function resolvePackageRoot(startDir) {
32
+ const overriddenPackageRoot = process.env[PROJECT_TOOLS_PACKAGE_ROOT_ENV]?.trim();
33
+ if (overriddenPackageRoot) {
34
+ const resolvedOverride = path.resolve(overriddenPackageRoot);
35
+ const validOverride = resolveValidProjectToolsPackageRoot(resolvedOverride);
36
+ if (validOverride) {
37
+ return validOverride;
38
+ }
39
+ }
7
40
  let currentDir = startDir;
8
41
  while (true) {
9
42
  const packageJsonPath = path.join(currentDir, "package.json");
10
43
  if (fs.existsSync(packageJsonPath)) {
11
44
  try {
12
45
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
13
- if (packageJson.name === "@wp-typia/project-tools") {
46
+ if (packageJson.name === PROJECT_TOOLS_PACKAGE_NAME) {
14
47
  return currentDir;
15
48
  }
16
49
  }
@@ -3,6 +3,7 @@ import type { SeedSource, TemplateVariableContext } from './template-source-cont
3
3
  * Candidate filenames for official external template config entrypoints.
4
4
  */
5
5
  export declare const EXTERNAL_TEMPLATE_ENTRY_CANDIDATES: readonly ["index.js", "index.cjs", "index.mjs"];
6
+ export declare const EXTERNAL_TEMPLATE_TRUST_WARNING = "External template configs execute trusted JavaScript during scaffolding. Review the template source before using local paths, GitHub repos, or npm packages you do not already trust.";
6
7
  /**
7
8
  * Search a source directory for the first supported external template entry.
8
9
  *