oh-my-customcode 1.1.101 → 1.1.103

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 (52) hide show
  1. package/dist/cli/index.js +518 -53
  2. package/dist/index.js +135 -6
  3. package/package.json +1 -1
  4. package/templates/.claude/output-styles/korean-engineer.md +1 -1
  5. package/templates/.claude/rules/MUST-agent-design.md +2 -2
  6. package/templates/.claude/rules/MUST-agent-teams.md +13 -2
  7. package/templates/.claude/rules/MUST-orchestrator-coordination.md +41 -29
  8. package/templates/.claude/rules/MUST-permissions.md +12 -9
  9. package/templates/.claude/skills/action-validator/SKILL.md +2 -2
  10. package/templates/.claude/skills/adaptive-harness/SKILL.md +2 -2
  11. package/templates/.claude/skills/adversarial-review/SKILL.md +1 -1
  12. package/templates/.claude/skills/agent-eval-framework/SKILL.md +2 -2
  13. package/templates/.claude/skills/de-lead-routing/SKILL.md +2 -2
  14. package/templates/.claude/skills/deep-plan/SKILL.md +4 -4
  15. package/templates/.claude/skills/deep-verify/SKILL.md +3 -3
  16. package/templates/.claude/skills/design-shotgun/SKILL.md +1 -1
  17. package/templates/.claude/skills/dev-lead-routing/SKILL.md +2 -2
  18. package/templates/.claude/skills/dev-review/SKILL.md +2 -2
  19. package/templates/.claude/skills/evaluator-optimizer/SKILL.md +1 -1
  20. package/templates/.claude/skills/fsd/SKILL.md +3 -4
  21. package/templates/.claude/skills/harness-eval/SKILL.md +1 -1
  22. package/templates/.claude/skills/harness-synthesizer/SKILL.md +1 -1
  23. package/templates/.claude/skills/homework/SKILL.md +3 -3
  24. package/templates/.claude/skills/idea/SKILL.md +4 -2
  25. package/templates/.claude/skills/instinct-extractor/SKILL.md +8 -8
  26. package/templates/.claude/skills/intent-detection/SKILL.md +1 -1
  27. package/templates/.claude/skills/multi-model-verification/SKILL.md +2 -2
  28. package/templates/.claude/skills/omcustom-release-notes/SKILL.md +1 -1
  29. package/templates/.claude/skills/pipeline/SKILL.md +1 -1
  30. package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +26 -3
  31. package/templates/.claude/skills/post-release-followup/SKILL.md +2 -2
  32. package/templates/.claude/skills/professor-triage/SKILL.md +10 -10
  33. package/templates/.claude/skills/profile/SKILL.md +4 -4
  34. package/templates/.claude/skills/qa-lead-routing/SKILL.md +2 -2
  35. package/templates/.claude/skills/release-plan/SKILL.md +1 -1
  36. package/templates/.claude/skills/research/SKILL.md +8 -6
  37. package/templates/.claude/skills/result-aggregation/SKILL.md +1 -1
  38. package/templates/.claude/skills/roundtable-debate/SKILL.md +2 -2
  39. package/templates/.claude/skills/scout/SKILL.md +3 -8
  40. package/templates/.claude/skills/sec-agentshield-wrapper/SKILL.md +1 -1
  41. package/templates/.claude/skills/secretary-routing/SKILL.md +2 -2
  42. package/templates/.claude/skills/skill-extractor/SKILL.md +1 -1
  43. package/templates/.claude/skills/structured-dev-cycle/SKILL.md +1 -1
  44. package/templates/.claude/skills/task-decomposition/SKILL.md +1 -1
  45. package/templates/.claude/skills/worker-reviewer-pipeline/SKILL.md +5 -1
  46. package/templates/guides/claude-code/15-version-compatibility.md +149 -2
  47. package/templates/guides/deep-plan/phases.md +11 -9
  48. package/templates/guides/harness-engineering/README.md +2 -1
  49. package/templates/guides/professor-triage/README.md +1 -1
  50. package/templates/guides/professor-triage/phases.md +1 -1
  51. package/templates/manifest.json +1 -1
  52. package/templates/workflows/auto-dev.yaml +26 -3
package/dist/cli/index.js CHANGED
@@ -335,7 +335,7 @@ var init_package = __esm(() => {
335
335
  workspaces: [
336
336
  "packages/*"
337
337
  ],
338
- version: "1.1.101",
338
+ version: "1.1.103",
339
339
  description: "Batteries-included agent harness for Claude Code",
340
340
  type: "module",
341
341
  bin: {
@@ -7331,7 +7331,7 @@ var require_public_api = __commonJS(function(exports) {
7331
7331
  });
7332
7332
 
7333
7333
  // src/utils/fs.ts
7334
- import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative, resolve as resolve2, sep as sep3 } from "node:path";
7334
+ import { dirname as dirname3, isAbsolute as isAbsolute3, join as join4, relative, resolve as resolve2, sep as sep3 } from "node:path";
7335
7335
  import { fileURLToPath } from "node:url";
7336
7336
  function validatePreserveFilePath(filePath, projectRoot) {
7337
7337
  if (!filePath || filePath.trim() === "") {
@@ -7340,7 +7340,7 @@ function validatePreserveFilePath(filePath, projectRoot) {
7340
7340
  reason: "Path cannot be empty"
7341
7341
  };
7342
7342
  }
7343
- if (isAbsolute2(filePath)) {
7343
+ if (isAbsolute3(filePath)) {
7344
7344
  return {
7345
7345
  valid: false,
7346
7346
  reason: "Absolute paths are not allowed"
@@ -7348,7 +7348,7 @@ function validatePreserveFilePath(filePath, projectRoot) {
7348
7348
  }
7349
7349
  const resolvedPath = resolve2(projectRoot, filePath);
7350
7350
  const relativePath = relative(projectRoot, resolvedPath);
7351
- if (relativePath.startsWith("..") || isAbsolute2(relativePath)) {
7351
+ if (relativePath.startsWith("..") || isAbsolute3(relativePath)) {
7352
7352
  return {
7353
7353
  valid: false,
7354
7354
  reason: "Path cannot traverse outside project root"
@@ -11972,8 +11972,7 @@ init_registry();
11972
11972
  init_package();
11973
11973
  import { execSync as execSync2, spawnSync } from "node:child_process";
11974
11974
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11975
- import { homedir as homedir2 } from "node:os";
11976
- import { dirname as dirname2, join as join3 } from "node:path";
11975
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3 } from "node:path";
11977
11976
  import { createInterface } from "node:readline/promises";
11978
11977
 
11979
11978
  // node_modules/.bun/i18next@26.0.2+ff0372d108591aba/node_modules/i18next/dist/esm/i18next.js
@@ -14665,7 +14664,9 @@ var en_default = {
14665
14664
  openOption: "Open browser automatically after start",
14666
14665
  foregroundOption: "Run in foreground (not detached)",
14667
14666
  started: "Web UI started: http://localhost:{{port}}",
14668
- failed: "Failed to start Web UI server"
14667
+ failed: "Failed to start Web UI server",
14668
+ homeUnresolved: "Cannot start the Web UI server: the home directory cannot be resolved, either because HOME is a relative path or because HOME, the OS-reported home and the passwd entry are all empty, so the PID file location cannot be determined. Set HOME to an absolute path.",
14669
+ pidNotWritable: "Cannot start the Web UI server: the PID file {{path}} cannot be written ({{error}}). Check the PID file path and its parent directory."
14669
14670
  },
14670
14671
  stop: {
14671
14672
  description: "Stop the Web UI server",
@@ -15083,7 +15084,9 @@ var ko_default = {
15083
15084
  openOption: "시작 후 브라우저 자동 열기",
15084
15085
  foregroundOption: "포그라운드 실행 (백그라운드 분리 안 함)",
15085
15086
  started: "Web UI 시작됨: http://localhost:{{port}}",
15086
- failed: "Web UI 서버 시작 실패"
15087
+ failed: "Web UI 서버 시작 실패",
15088
+ homeUnresolved: "Web UI 서버를 시작할 수 없습니다: 홈 디렉토리를 확인할 수 없어 PID 파일 위치를 정할 수 없습니다. HOME이 상대 경로이거나, HOME·OS 보고 홈·passwd 항목이 모두 비어 있는 경우입니다. HOME을 절대 경로로 설정하세요.",
15089
+ pidNotWritable: "Web UI 서버를 시작할 수 없습니다: PID 파일 {{path}}에 쓸 수 없습니다 ({{error}}). PID 파일 경로와 그 상위 디렉토리를 확인하세요."
15087
15090
  },
15088
15091
  stop: {
15089
15092
  description: "Web UI 서버 중지",
@@ -15269,9 +15272,18 @@ var i18n = {
15269
15272
  };
15270
15273
 
15271
15274
  // src/core/self-update.ts
15275
+ init_home();
15272
15276
  var DEFAULT_PACKAGE_NAME = "oh-my-customcode";
15273
15277
  var DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
15274
- var DEFAULT_CACHE_PATH = join3(homedir2(), ".oh-my-customcode", "self-update-cache.json");
15278
+ var CACHE_DIR_NAME = ".oh-my-customcode";
15279
+ var CACHE_FILE_NAME = "self-update-cache.json";
15280
+ function resolveSelfUpdateCachePath(sources) {
15281
+ const home = resolveHomeDir(sources);
15282
+ if (!isAbsolute2(home)) {
15283
+ return null;
15284
+ }
15285
+ return join3(home, CACHE_DIR_NAME, CACHE_FILE_NAME);
15286
+ }
15275
15287
  var MS_EPOCH_THRESHOLD = 1000000000000;
15276
15288
  function normalizeVersion(version) {
15277
15289
  return version.trim().replace(/^v/i, "").split("-")[0] || "";
@@ -15367,20 +15379,36 @@ function readSelfUpdateCache(cachePath) {
15367
15379
  }
15368
15380
  return null;
15369
15381
  }
15382
+ function readCacheAt(cachePath) {
15383
+ return cachePath === null ? null : readSelfUpdateCache(cachePath);
15384
+ }
15385
+ function isFileSystemError(error) {
15386
+ const { code, errno } = error ?? {};
15387
+ return typeof code === "string" && typeof errno === "number";
15388
+ }
15370
15389
  function writeCache(cachePath, latestVersion, now) {
15371
- const dir = dirname2(cachePath);
15372
- if (!existsSync(dir)) {
15373
- mkdirSync(dir, { recursive: true });
15390
+ if (cachePath === null) {
15391
+ return;
15374
15392
  }
15375
- const payload = {
15376
- checkedAt: new Date(now).toISOString(),
15377
- latestVersion,
15378
- version: latestVersion,
15379
- timestamp: Math.floor(now / 1000),
15380
- source: "omcustom-cli"
15381
- };
15382
- writeFileSync(cachePath, `${JSON.stringify(payload, null, 2)}
15393
+ try {
15394
+ const dir = dirname2(cachePath);
15395
+ if (!existsSync(dir)) {
15396
+ mkdirSync(dir, { recursive: true });
15397
+ }
15398
+ const payload = {
15399
+ checkedAt: new Date(now).toISOString(),
15400
+ latestVersion,
15401
+ version: latestVersion,
15402
+ timestamp: Math.floor(now / 1000),
15403
+ source: "omcustom-cli"
15404
+ };
15405
+ writeFileSync(cachePath, `${JSON.stringify(payload, null, 2)}
15383
15406
  `, "utf-8");
15407
+ } catch (error) {
15408
+ if (!isFileSystemError(error)) {
15409
+ throw error;
15410
+ }
15411
+ }
15384
15412
  }
15385
15413
  function isCacheFresh(cache, now, cacheTtlMs) {
15386
15414
  const checkedAt = new Date(cache.checkedAt).getTime();
@@ -15520,7 +15548,7 @@ function executeSelfUpdate(options = {}) {
15520
15548
  }
15521
15549
  function checkSelfUpdate(options) {
15522
15550
  const packageName = options.packageName || DEFAULT_PACKAGE_NAME;
15523
- const cachePath = options.cachePath || DEFAULT_CACHE_PATH;
15551
+ const cachePath = options.cachePath || resolveSelfUpdateCachePath();
15524
15552
  const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
15525
15553
  const fetchLatestVersion = options.fetchLatestVersion || fetchLatestVersionFromNpm;
15526
15554
  const now = options.now ?? Date.now();
@@ -15536,7 +15564,7 @@ function checkSelfUpdate(options) {
15536
15564
  }
15537
15565
  let latestVersion = null;
15538
15566
  let usedCache = false;
15539
- const cache = readSelfUpdateCache(cachePath);
15567
+ const cache = readCacheAt(cachePath);
15540
15568
  if (cache && isCacheFresh(cache, now, cacheTtlMs)) {
15541
15569
  const cachedVersion = normalizeVersion(cache.latestVersion);
15542
15570
  if (isVersionPlausible(currentVersion, cachedVersion)) {
@@ -15758,6 +15786,7 @@ var MESSAGES = {
15758
15786
  "lockfile.component_dir_missing": "Component directory missing: {{path}}",
15759
15787
  "lockfile.hash_failed": "Failed to hash file: {{path}} — {{error}}",
15760
15788
  "lockfile.entry_added": "Lockfile entry added: {{path}} ({{component}})",
15789
+ "lockfile.entry_git_excluded": "Lockfile entry skipped (not in git listing): {{path}}",
15761
15790
  "update.start": "Checking for updates...",
15762
15791
  "update.success": "Updated from {{from}} to {{to}}",
15763
15792
  "update.components_synced": "Components synced (version {{version}}): {{components}}",
@@ -15879,6 +15908,7 @@ var MESSAGES = {
15879
15908
  "lockfile.component_dir_missing": "컴포넌트 디렉토리 없음: {{path}}",
15880
15909
  "lockfile.hash_failed": "파일 해시 실패: {{path}} — {{error}}",
15881
15910
  "lockfile.entry_added": "잠금 파일 항목 추가: {{path}} ({{component}})",
15911
+ "lockfile.entry_git_excluded": "잠금 파일 항목 제외 (git 목록에 없음): {{path}}",
15882
15912
  "update.start": "업데이트 확인 중...",
15883
15913
  "update.success": "{{from}}에서 {{to}}로 업데이트 완료",
15884
15914
  "update.components_synced": "컴포넌트 동기화 완료 (버전 {{version}}): {{components}}",
@@ -16334,10 +16364,12 @@ function getComponentPath(component) {
16334
16364
 
16335
16365
  // src/core/lockfile.ts
16336
16366
  init_fs();
16367
+ import { execFile } from "node:child_process";
16337
16368
  import { createHash } from "node:crypto";
16338
16369
  import { createReadStream } from "node:fs";
16339
- import { readdir, stat } from "node:fs/promises";
16370
+ import { readdir, realpath, stat } from "node:fs/promises";
16340
16371
  import { join as join7, relative as relative2 } from "node:path";
16372
+ import { promisify } from "node:util";
16341
16373
  var LOCKFILE_NAME = ".omcustom.lock.json";
16342
16374
  var LOCKFILE_VERSION = 1;
16343
16375
  var LOCKFILE_COMPONENTS = [
@@ -16350,6 +16382,87 @@ var LOCKFILE_COMPONENTS = [
16350
16382
  "guides"
16351
16383
  ];
16352
16384
  var COMPONENT_PATHS = LOCKFILE_COMPONENTS.map((component) => [getComponentPath(component), component]);
16385
+ var execFileAsync = promisify(execFile);
16386
+ var GIT_LOCATION_ENV_KEYS = [
16387
+ "GIT_DIR",
16388
+ "GIT_WORK_TREE",
16389
+ "GIT_INDEX_FILE",
16390
+ "GIT_PREFIX",
16391
+ "GIT_COMMON_DIR"
16392
+ ];
16393
+ var GIT_INJECTED_CONFIG_ENV_KEYS = ["GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT"];
16394
+ var GIT_INJECTED_CONFIG_ENV_PREFIXES = ["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"];
16395
+ var GIT_REQUIRED_HINT = "build from a git checkout: excluding ignored local files from the lockfile requires git (#1819)";
16396
+ function gitChildEnv() {
16397
+ const env = { ...process.env };
16398
+ for (const key of [...GIT_LOCATION_ENV_KEYS, ...GIT_INJECTED_CONFIG_ENV_KEYS]) {
16399
+ delete env[key];
16400
+ }
16401
+ for (const key of Object.keys(env)) {
16402
+ if (GIT_INJECTED_CONFIG_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) {
16403
+ delete env[key];
16404
+ }
16405
+ }
16406
+ return env;
16407
+ }
16408
+ async function runGit(targetDir, args, env) {
16409
+ try {
16410
+ const { stdout } = await execFileAsync("git", args, {
16411
+ cwd: targetDir,
16412
+ env,
16413
+ encoding: "utf-8",
16414
+ maxBuffer: 256 * 1024 * 1024
16415
+ });
16416
+ return stdout;
16417
+ } catch (err) {
16418
+ const detail = err instanceof Error ? err.message.trim() : String(err);
16419
+ throw new Error(`git ${args[0]} failed in ${targetDir}: ${detail} — ${GIT_REQUIRED_HINT}`);
16420
+ }
16421
+ }
16422
+ function foldCase(nfcPath) {
16423
+ return nfcPath.toLowerCase();
16424
+ }
16425
+ function isGitVisible(visible, relativePath) {
16426
+ if (visible.exact.has(relativePath)) {
16427
+ return true;
16428
+ }
16429
+ const nfc = relativePath.normalize("NFC");
16430
+ if (visible.nfc.has(nfc)) {
16431
+ return true;
16432
+ }
16433
+ return visible.folded?.has(foldCase(nfc)) ?? false;
16434
+ }
16435
+ function stripFinalNewline(output) {
16436
+ return output.endsWith(`
16437
+ `) ? output.slice(0, -1) : output;
16438
+ }
16439
+ async function assertGitWorkTreeRoot(targetDir, env) {
16440
+ const topLevel = stripFinalNewline(await runGit(targetDir, ["rev-parse", "--show-toplevel"], env));
16441
+ let realTop;
16442
+ let realTarget;
16443
+ try {
16444
+ [realTop, realTarget] = await Promise.all([realpath(topLevel), realpath(targetDir)]);
16445
+ } catch (err) {
16446
+ const detail = err instanceof Error ? err.message : String(err);
16447
+ throw new Error(`cannot resolve the git work tree root of ${targetDir}: ${detail} — ${GIT_REQUIRED_HINT}`);
16448
+ }
16449
+ if (realTop !== realTarget) {
16450
+ throw new Error(`${targetDir} is not the root of its git work tree (root: ${topLevel}) — ${GIT_REQUIRED_HINT}`);
16451
+ }
16452
+ }
16453
+ async function listGitVisiblePaths(targetDir) {
16454
+ const env = gitChildEnv();
16455
+ await assertGitWorkTreeRoot(targetDir, env);
16456
+ const pathspecs = COMPONENT_PATHS.map(([prefix]) => prefix);
16457
+ const [listing, ignoreCase] = await Promise.all([
16458
+ runGit(targetDir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspecs], env),
16459
+ runGit(targetDir, ["config", "--type=bool", "--default=false", "--get", "core.ignorecase"], env)
16460
+ ]);
16461
+ const exact = new Set(listing.split("\x00").filter((entry) => entry.length > 0));
16462
+ const nfc = new Set([...exact].map((entry) => entry.normalize("NFC")));
16463
+ const folded = stripFinalNewline(ignoreCase) === "true" ? new Set([...nfc].map(foldCase)) : null;
16464
+ return { exact, nfc, folded };
16465
+ }
16353
16466
  function computeFileHash(filePath) {
16354
16467
  return new Promise((resolve, reject) => {
16355
16468
  const hash = createHash("sha256");
@@ -16431,8 +16544,13 @@ async function collectFiles(dir, projectRoot, isTopLevel) {
16431
16544
  }
16432
16545
  return results;
16433
16546
  }
16434
- async function generateLockfile(targetDir, generatorVersion, templateVersion) {
16547
+ function sortFilesByPath(files) {
16548
+ const sorted = Object.keys(files).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
16549
+ return Object.fromEntries(sorted.map((key) => [key, files[key]]));
16550
+ }
16551
+ async function generateLockfile(targetDir, generatorVersion, templateVersion, options = {}) {
16435
16552
  const files = {};
16553
+ const gitVisible = options.excludeGitIgnored ? await listGitVisiblePaths(targetDir) : null;
16436
16554
  const componentRoots = COMPONENT_PATHS.map(([prefix]) => join7(targetDir, prefix));
16437
16555
  for (const componentRoot of componentRoots) {
16438
16556
  const exists = await fileExists(componentRoot);
@@ -16443,6 +16561,10 @@ async function generateLockfile(targetDir, generatorVersion, templateVersion) {
16443
16561
  const allFiles = await collectFiles(componentRoot, targetDir, false);
16444
16562
  for (const absolutePath of allFiles) {
16445
16563
  const relativePath = relative2(targetDir, absolutePath).replace(/\\/g, "/");
16564
+ if (gitVisible !== null && !isGitVisible(gitVisible, relativePath)) {
16565
+ debug("lockfile.entry_git_excluded", { path: relativePath });
16566
+ continue;
16567
+ }
16446
16568
  let hash;
16447
16569
  let size;
16448
16570
  try {
@@ -16467,15 +16589,50 @@ async function generateLockfile(targetDir, generatorVersion, templateVersion) {
16467
16589
  generatorVersion,
16468
16590
  generatedAt: new Date().toISOString(),
16469
16591
  templateVersion,
16470
- files
16592
+ files: sortFilesByPath(files)
16471
16593
  };
16472
16594
  }
16473
- async function generateAndWriteLockfileForDir(targetDir) {
16595
+ function hasSameLockfileContent(next, previous) {
16596
+ if (next.lockfileVersion !== previous.lockfileVersion || next.generatorVersion !== previous.generatorVersion || next.templateVersion !== previous.templateVersion) {
16597
+ return false;
16598
+ }
16599
+ const nextKeys = Object.keys(next.files);
16600
+ if (nextKeys.length !== Object.keys(previous.files).length) {
16601
+ return false;
16602
+ }
16603
+ return nextKeys.every((key) => {
16604
+ if (!Object.hasOwn(previous.files, key)) {
16605
+ return false;
16606
+ }
16607
+ const before = previous.files[key];
16608
+ const after = next.files[key];
16609
+ return typeof before === "object" && before !== null && before.templateHash === after.templateHash && before.size === after.size && before.component === after.component;
16610
+ });
16611
+ }
16612
+ function isIsoTimestamp(value) {
16613
+ if (typeof value !== "string") {
16614
+ return false;
16615
+ }
16616
+ const time = new Date(value).getTime();
16617
+ return !Number.isNaN(time) && new Date(time).toISOString() === value;
16618
+ }
16619
+ function preserveGeneratedAt(next, previous) {
16620
+ if (previous === null || !isIsoTimestamp(previous.generatedAt)) {
16621
+ return next;
16622
+ }
16623
+ if (!hasSameLockfileContent(next, previous)) {
16624
+ return next;
16625
+ }
16626
+ return { ...next, generatedAt: previous.generatedAt };
16627
+ }
16628
+ async function generateAndWriteLockfileForDir(targetDir, options = {}) {
16474
16629
  try {
16475
16630
  const packageRoot = getPackageRoot();
16476
16631
  const manifest = await readJsonFile(join7(packageRoot, "templates", "manifest.json"));
16477
16632
  const { version: generatorVersion } = await readJsonFile(join7(packageRoot, "package.json"));
16478
- const lockfile = await generateLockfile(targetDir, generatorVersion, manifest.version);
16633
+ const generated = await generateLockfile(targetDir, generatorVersion, manifest.version, options);
16634
+ const previous = await readLockfile(targetDir);
16635
+ const lockfile = preserveGeneratedAt(generated, previous);
16479
16636
  await writeLockfile(targetDir, lockfile);
16480
16637
  return { fileCount: Object.keys(lockfile.files).length };
16481
16638
  } catch (err) {
@@ -20869,12 +21026,50 @@ import { spawnSync as spawnSync2 } from "node:child_process";
20869
21026
  import { join as join17 } from "node:path";
20870
21027
 
20871
21028
  // src/cli/serve.ts
21029
+ init_home();
20872
21030
  import { spawn } from "node:child_process";
20873
- import { existsSync as existsSync3 } from "node:fs";
20874
- import { readFile as readFile3, unlink, writeFile as writeFile3 } from "node:fs/promises";
20875
- import { join as join16 } from "node:path";
21031
+ import { randomUUID } from "node:crypto";
21032
+ import { constants as constants3, existsSync as existsSync3 } from "node:fs";
21033
+ import {
21034
+ access,
21035
+ link,
21036
+ readdir as readdir3,
21037
+ readFile as readFile3,
21038
+ readlink,
21039
+ rename as rename2,
21040
+ stat as stat3,
21041
+ unlink,
21042
+ writeFile as writeFile3
21043
+ } from "node:fs/promises";
21044
+ import { dirname as dirname7, isAbsolute as isAbsolute4, join as join16 } from "node:path";
21045
+ import { setTimeout as delay } from "node:timers/promises";
20876
21046
  var DEFAULT_PORT = 4321;
20877
- var PID_FILE = join16(process.env.HOME ?? "~", ".omcustom-serve.pid");
21047
+ var PID_FILE_NAME = ".omcustom-serve.pid";
21048
+ var STARTING_RECORD = /^starting:(\d+):/;
21049
+ var MAX_CLAIM_ATTEMPTS = 3;
21050
+ var CLAIM_MAX_AGE_MS = 60000;
21051
+ var PARTIAL_RECORD_GRACE_MS = 1e4;
21052
+ var ABSENT_RETRY_DELAY_MS = 10;
21053
+ var HARD_LINK_UNSUPPORTED = new Set(["ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV"]);
21054
+ var SIBLING_REMNANT = /^\.omcustom-serve\.pid\.(\d+)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:tmp|taken)$/;
21055
+
21056
+ class ServePidFileError extends Error {
21057
+ reason;
21058
+ pidFile;
21059
+ constructor(reason, pidFile, options) {
21060
+ super(reason === "home-unresolved" ? "Cannot resolve the serve PID file: the home directory is empty or relative" : `Cannot write the serve PID file: ${pidFile}`, options);
21061
+ this.name = "ServePidFileError";
21062
+ this.reason = reason;
21063
+ this.pidFile = pidFile;
21064
+ }
21065
+ }
21066
+ function resolveServePidFile(sources) {
21067
+ const home = resolveHomeDir(sources);
21068
+ if (!isAbsolute4(home)) {
21069
+ return null;
21070
+ }
21071
+ return join16(home, PID_FILE_NAME);
21072
+ }
20878
21073
  function findServeBuildDir(projectRoot, options) {
20879
21074
  const localBuild = join16(projectRoot, "packages", "serve", "build");
20880
21075
  if (existsSync3(join16(localBuild, "index.js")))
@@ -20886,29 +21081,94 @@ function findServeBuildDir(projectRoot, options) {
20886
21081
  }
20887
21082
  return null;
20888
21083
  }
20889
- async function isServeRunning() {
21084
+ function parsePidRecord(raw) {
21085
+ const text = raw.trim();
21086
+ const match = STARTING_RECORD.exec(text);
21087
+ const pid = Number(match === null ? text : match[1]);
21088
+ if (!Number.isFinite(pid) || pid <= 0) {
21089
+ return null;
21090
+ }
21091
+ return { pid, starting: match !== null };
21092
+ }
21093
+ function isProcessAlive(pid) {
20890
21094
  try {
20891
- const raw = await readFile3(PID_FILE, "utf-8");
20892
- const pid = Number(raw.trim());
20893
- if (!Number.isFinite(pid) || pid <= 0) {
20894
- await cleanupPidFile();
20895
- return false;
20896
- }
20897
21095
  process.kill(pid, 0);
20898
21096
  return true;
20899
21097
  } catch {
20900
- await cleanupPidFile();
20901
21098
  return false;
20902
21099
  }
20903
21100
  }
21101
+ function errorCode(error) {
21102
+ return error?.code;
21103
+ }
21104
+ async function isOlderThan(path, ms) {
21105
+ try {
21106
+ const { mtimeMs } = await stat3(path);
21107
+ return Date.now() - mtimeMs >= ms;
21108
+ } catch {
21109
+ return false;
21110
+ }
21111
+ }
21112
+ async function isRecordLive(pidFile, record) {
21113
+ if (!isProcessAlive(record.pid)) {
21114
+ return false;
21115
+ }
21116
+ return !record.starting || !await isOlderThan(pidFile, CLAIM_MAX_AGE_MS);
21117
+ }
21118
+ async function readPidFileSettled(pidFile) {
21119
+ try {
21120
+ return await readFile3(pidFile, "utf-8");
21121
+ } catch (error) {
21122
+ if (errorCode(error) !== "ENOENT") {
21123
+ throw error;
21124
+ }
21125
+ }
21126
+ await delay(ABSENT_RETRY_DELAY_MS);
21127
+ return readFile3(pidFile, "utf-8");
21128
+ }
21129
+ async function isServeRunning() {
21130
+ const pidFile = resolveServePidFile();
21131
+ if (pidFile === null) {
21132
+ return false;
21133
+ }
21134
+ return isServeRunningAt(pidFile);
21135
+ }
21136
+ async function isServeRunningAt(pidFile) {
21137
+ let raw;
21138
+ try {
21139
+ raw = await readPidFileSettled(pidFile);
21140
+ } catch {
21141
+ return false;
21142
+ }
21143
+ const record = parsePidRecord(raw);
21144
+ return record !== null && await isRecordLive(pidFile, record);
21145
+ }
20904
21146
  async function startServeBackground(projectRoot, port = DEFAULT_PORT, buildDirOpts) {
20905
- if (await isServeRunning()) {
21147
+ const pidFile = resolveServePidFile();
21148
+ if (pidFile === null) {
21149
+ throw new ServePidFileError("home-unresolved", null);
21150
+ }
21151
+ if (await isServeRunningAt(pidFile)) {
20906
21152
  return;
20907
21153
  }
20908
21154
  const buildDir = findServeBuildDir(projectRoot, buildDirOpts);
20909
21155
  if (buildDir === null) {
20910
21156
  return;
20911
21157
  }
21158
+ try {
21159
+ await access(dirname7(pidFile), constants3.W_OK);
21160
+ } catch (error) {
21161
+ throw new ServePidFileError("pid-not-writable", pidFile, { cause: error });
21162
+ }
21163
+ let claim;
21164
+ try {
21165
+ claim = await claimPidFile(pidFile);
21166
+ } catch (error) {
21167
+ throw new ServePidFileError("pid-not-writable", pidFile, { cause: error });
21168
+ }
21169
+ if (claim === null) {
21170
+ return;
21171
+ }
20912
21172
  const child = spawn("node", [join16(buildDir, "index.js")], {
20913
21173
  env: {
20914
21174
  ...process.env,
@@ -20920,30 +21180,220 @@ async function startServeBackground(projectRoot, port = DEFAULT_PORT, buildDirOp
20920
21180
  stdio: "ignore",
20921
21181
  detached: true
20922
21182
  });
21183
+ child.on("error", () => {});
20923
21184
  child.unref();
20924
- if (child.pid !== undefined) {
20925
- await writeFile3(PID_FILE, String(child.pid), "utf-8");
21185
+ if (child.pid === undefined) {
21186
+ await removePidFileIf(pidFile, claim);
21187
+ return;
21188
+ }
21189
+ let recorded;
21190
+ try {
21191
+ recorded = await recordServerPid(pidFile, claim, String(child.pid));
21192
+ } catch (error) {
21193
+ child.kill("SIGTERM");
21194
+ await removePidFileIf(pidFile, claim);
21195
+ throw new ServePidFileError("pid-not-writable", pidFile, { cause: error });
21196
+ }
21197
+ if (!recorded) {
21198
+ child.kill("SIGTERM");
20926
21199
  }
20927
21200
  }
21201
+ async function recordServerPid(pidFile, claim, pid) {
21202
+ for (let attempt = 0;attempt < MAX_CLAIM_ATTEMPTS; attempt++) {
21203
+ if (await createPidFileExclusive(pidFile, pid)) {
21204
+ return true;
21205
+ }
21206
+ let current;
21207
+ try {
21208
+ current = await readFile3(pidFile, "utf-8");
21209
+ } catch (error) {
21210
+ if (errorCode(error) === "ENOENT") {
21211
+ continue;
21212
+ }
21213
+ throw error;
21214
+ }
21215
+ if (current !== claim) {
21216
+ return false;
21217
+ }
21218
+ await replacePidFile(pidFile, pid);
21219
+ return true;
21220
+ }
21221
+ return false;
21222
+ }
20928
21223
  async function stopServe() {
21224
+ const pidFile = resolveServePidFile();
21225
+ if (pidFile === null) {
21226
+ return false;
21227
+ }
21228
+ let raw;
20929
21229
  try {
20930
- const raw = await readFile3(PID_FILE, "utf-8");
20931
- const pid = Number(raw.trim());
20932
- if (!Number.isFinite(pid) || pid <= 0) {
20933
- await cleanupPidFile();
20934
- return false;
21230
+ raw = await readPidFileSettled(pidFile);
21231
+ } catch {
21232
+ return false;
21233
+ }
21234
+ const record = parsePidRecord(raw);
21235
+ if (record === null || record.starting) {
21236
+ return false;
21237
+ }
21238
+ try {
21239
+ process.kill(record.pid, "SIGTERM");
21240
+ } catch {
21241
+ return false;
21242
+ }
21243
+ await removePidFileIf(pidFile, raw);
21244
+ return true;
21245
+ }
21246
+ function siblingPath(pidFile, suffix) {
21247
+ return `${pidFile}.${process.pid}.${randomUUID()}.${suffix}`;
21248
+ }
21249
+ async function claimPidFile(pidFile) {
21250
+ await removeDeadSiblings(pidFile);
21251
+ const claim = `starting:${process.pid}:${randomUUID()}`;
21252
+ for (let attempt = 0;attempt < MAX_CLAIM_ATTEMPTS; attempt++) {
21253
+ if (await createPidFileExclusive(pidFile, claim)) {
21254
+ return claim;
21255
+ }
21256
+ let raw;
21257
+ try {
21258
+ raw = await readFile3(pidFile, "utf-8");
21259
+ } catch (error) {
21260
+ if (errorCode(error) === "ENOENT") {
21261
+ await removeDanglingLink(pidFile);
21262
+ continue;
21263
+ }
21264
+ throw error;
21265
+ }
21266
+ if (await isRecordHeld(pidFile, raw)) {
21267
+ return null;
20935
21268
  }
20936
- process.kill(pid, "SIGTERM");
20937
- await cleanupPidFile();
21269
+ await removePidFileIf(pidFile, raw);
21270
+ }
21271
+ return null;
21272
+ }
21273
+ async function isRecordHeld(pidFile, raw) {
21274
+ const record = parsePidRecord(raw);
21275
+ if (record === null) {
21276
+ return isPossiblyPartialRecord(raw) && !await isOlderThan(pidFile, PARTIAL_RECORD_GRACE_MS);
21277
+ }
21278
+ return isRecordLive(pidFile, record);
21279
+ }
21280
+ function isPossiblyPartialRecord(raw) {
21281
+ return "starting:".startsWith(raw) || /^starting:\d*$/.test(raw);
21282
+ }
21283
+ async function removeDeadSiblings(pidFile) {
21284
+ const dir = dirname7(pidFile);
21285
+ let names;
21286
+ try {
21287
+ names = await readdir3(dir);
21288
+ } catch {
21289
+ return;
21290
+ }
21291
+ for (const name of names) {
21292
+ const match = SIBLING_REMNANT.exec(name);
21293
+ if (match !== null && isProcessGone(Number(match[1]))) {
21294
+ await unlinkQuietly(join16(dir, name));
21295
+ }
21296
+ }
21297
+ }
21298
+ function isProcessGone(pid) {
21299
+ try {
21300
+ process.kill(pid, 0);
21301
+ return false;
21302
+ } catch (error) {
21303
+ return errorCode(error) === "ESRCH";
21304
+ }
21305
+ }
21306
+ async function createPidFileExclusive(pidFile, content) {
21307
+ const staged = siblingPath(pidFile, "tmp");
21308
+ try {
21309
+ await writeFile3(staged, content, { encoding: "utf-8", flag: "wx" });
21310
+ await linkExclusive(staged, pidFile, content);
20938
21311
  return true;
21312
+ } catch (error) {
21313
+ if (errorCode(error) === "EEXIST") {
21314
+ return false;
21315
+ }
21316
+ throw error;
21317
+ } finally {
21318
+ await unlinkQuietly(staged);
21319
+ }
21320
+ }
21321
+ async function linkExclusive(source, target, content) {
21322
+ try {
21323
+ await link(source, target);
21324
+ } catch (error) {
21325
+ if (!HARD_LINK_UNSUPPORTED.has(errorCode(error) ?? "")) {
21326
+ throw error;
21327
+ }
21328
+ await writeFile3(target, content, { encoding: "utf-8", flag: "wx" });
21329
+ }
21330
+ }
21331
+ async function replacePidFile(pidFile, content) {
21332
+ const staged = siblingPath(pidFile, "tmp");
21333
+ try {
21334
+ await writeFile3(staged, content, { encoding: "utf-8", flag: "wx" });
21335
+ await rename2(staged, pidFile);
21336
+ } catch (error) {
21337
+ await unlinkQuietly(staged);
21338
+ throw error;
21339
+ }
21340
+ }
21341
+ async function removePidFileIf(pidFile, expected) {
21342
+ await removePidFileWhen(pidFile, async (taken) => {
21343
+ try {
21344
+ return await readFile3(taken, "utf-8") === expected;
21345
+ } catch {
21346
+ return false;
21347
+ }
21348
+ });
21349
+ }
21350
+ async function removeDanglingLink(pidFile) {
21351
+ if (!await isDanglingLink(pidFile)) {
21352
+ return;
21353
+ }
21354
+ await removePidFileWhen(pidFile, isDanglingLink);
21355
+ }
21356
+ async function isDanglingLink(path) {
21357
+ try {
21358
+ await readlink(path);
20939
21359
  } catch {
20940
- await cleanupPidFile();
20941
21360
  return false;
20942
21361
  }
21362
+ try {
21363
+ await stat3(path);
21364
+ return false;
21365
+ } catch {
21366
+ return true;
21367
+ }
21368
+ }
21369
+ async function removePidFileWhen(pidFile, isExpected) {
21370
+ const taken = siblingPath(pidFile, "taken");
21371
+ try {
21372
+ await rename2(pidFile, taken);
21373
+ } catch {
21374
+ return;
21375
+ }
21376
+ if (!await isExpected(taken)) {
21377
+ await putBack(taken, pidFile);
21378
+ }
21379
+ await unlinkQuietly(taken);
21380
+ }
21381
+ async function putBack(taken, pidFile) {
21382
+ try {
21383
+ await link(taken, pidFile);
21384
+ return;
21385
+ } catch (error) {
21386
+ if (!HARD_LINK_UNSUPPORTED.has(errorCode(error) ?? "")) {
21387
+ return;
21388
+ }
21389
+ }
21390
+ try {
21391
+ await writeFile3(pidFile, await readFile3(taken, "utf-8"), { encoding: "utf-8", flag: "wx" });
21392
+ } catch {}
20943
21393
  }
20944
- async function cleanupPidFile() {
21394
+ async function unlinkQuietly(path) {
20945
21395
  try {
20946
- await unlink(PID_FILE);
21396
+ await unlink(path);
20947
21397
  } catch {}
20948
21398
  }
20949
21399
 
@@ -20962,7 +21412,15 @@ async function serveCommand(options) {
20962
21412
  runForeground(cwd, port, buildDirOpts);
20963
21413
  return;
20964
21414
  }
20965
- await startServeBackground(cwd, port, buildDirOpts);
21415
+ try {
21416
+ await startServeBackground(cwd, port, buildDirOpts);
21417
+ } catch (error) {
21418
+ if (!(error instanceof ServePidFileError)) {
21419
+ throw error;
21420
+ }
21421
+ console.error(describePidFileError(error));
21422
+ process.exit(1);
21423
+ }
20966
21424
  const running = await isServeRunning();
20967
21425
  if (running) {
20968
21426
  console.log(i18n.t("cli.web.start.started", { port }));
@@ -20971,6 +21429,13 @@ async function serveCommand(options) {
20971
21429
  process.exit(1);
20972
21430
  }
20973
21431
  }
21432
+ function describePidFileError(error) {
21433
+ if (error.reason === "home-unresolved") {
21434
+ return i18n.t("cli.web.start.homeUnresolved");
21435
+ }
21436
+ const cause = error.cause instanceof Error ? error.cause.message : String(error.cause);
21437
+ return i18n.t("cli.web.start.pidNotWritable", { path: String(error.pidFile), error: cause });
21438
+ }
20974
21439
  async function serveStopCommand() {
20975
21440
  const stopped = await stopServe();
20976
21441
  if (stopped) {