@wp-typia/project-tools 0.20.1 → 0.21.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.
Files changed (48) hide show
  1. package/dist/runtime/cli-add-block.js +1 -1
  2. package/dist/runtime/cli-add-shared.d.ts +73 -5
  3. package/dist/runtime/cli-add-shared.js +58 -11
  4. package/dist/runtime/cli-add-workspace-ability.js +11 -57
  5. package/dist/runtime/cli-add-workspace-admin-view.d.ts +23 -0
  6. package/dist/runtime/cli-add-workspace-admin-view.js +872 -0
  7. package/dist/runtime/cli-add-workspace-ai-anchors.js +2 -5
  8. package/dist/runtime/cli-add-workspace-ai-source-emitters.d.ts +0 -4
  9. package/dist/runtime/cli-add-workspace-ai-source-emitters.js +7 -17
  10. package/dist/runtime/cli-add-workspace-ai.js +4 -6
  11. package/dist/runtime/cli-add-workspace-assets.d.ts +13 -5
  12. package/dist/runtime/cli-add-workspace-assets.js +290 -106
  13. package/dist/runtime/cli-add-workspace-rest-anchors.js +2 -5
  14. package/dist/runtime/cli-add-workspace-rest-source-emitters.d.ts +0 -1
  15. package/dist/runtime/cli-add-workspace-rest-source-emitters.js +7 -14
  16. package/dist/runtime/cli-add-workspace-rest.js +4 -6
  17. package/dist/runtime/cli-add-workspace.d.ts +58 -1
  18. package/dist/runtime/cli-add-workspace.js +588 -18
  19. package/dist/runtime/cli-add.d.ts +1 -1
  20. package/dist/runtime/cli-add.js +1 -1
  21. package/dist/runtime/cli-core.d.ts +8 -5
  22. package/dist/runtime/cli-core.js +7 -4
  23. package/dist/runtime/cli-diagnostics.d.ts +84 -1
  24. package/dist/runtime/cli-diagnostics.js +90 -3
  25. package/dist/runtime/cli-doctor-workspace.js +552 -13
  26. package/dist/runtime/cli-doctor.d.ts +4 -2
  27. package/dist/runtime/cli-doctor.js +2 -1
  28. package/dist/runtime/cli-help.js +19 -9
  29. package/dist/runtime/cli-init.d.ts +67 -3
  30. package/dist/runtime/cli-init.js +603 -64
  31. package/dist/runtime/cli-validation.js +4 -3
  32. package/dist/runtime/index.d.ts +9 -4
  33. package/dist/runtime/index.js +7 -3
  34. package/dist/runtime/package-json-types.d.ts +12 -0
  35. package/dist/runtime/package-json-types.js +1 -0
  36. package/dist/runtime/package-versions.d.ts +17 -2
  37. package/dist/runtime/package-versions.js +46 -1
  38. package/dist/runtime/php-utils.d.ts +16 -0
  39. package/dist/runtime/php-utils.js +59 -0
  40. package/dist/runtime/scaffold-answer-resolution.js +35 -10
  41. package/dist/runtime/scaffold-apply-utils.d.ts +2 -3
  42. package/dist/runtime/scaffold-apply-utils.js +3 -43
  43. package/dist/runtime/template-source-cache.d.ts +112 -0
  44. package/dist/runtime/template-source-cache.js +434 -0
  45. package/dist/runtime/template-source-seeds.js +333 -53
  46. package/dist/runtime/workspace-inventory.d.ts +43 -2
  47. package/dist/runtime/workspace-inventory.js +138 -5
  48. package/package.json +2 -2
@@ -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
+ }