oh-my-customcode 0.9.4 → 0.10.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.
package/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/oh-my-customcode.svg)](https://www.npmjs.com/package/oh-my-customcode)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
  [![CI](https://github.com/baekenough/oh-my-customcode/actions/workflows/ci.yml/badge.svg)](https://github.com/baekenough/oh-my-customcode/actions/workflows/ci.yml)
8
+ [![Security Audit](https://github.com/baekenough/oh-my-customcode/actions/workflows/security-audit.yml/badge.svg)](https://github.com/baekenough/oh-my-customcode/actions/workflows/security-audit.yml)
8
9
 
9
10
  **[한국어 문서 (Korean)](./README_ko.md)**
10
11
 
@@ -16,7 +17,7 @@ Like oh-my-zsh transformed shell customization, oh-my-customcode makes personali
16
17
 
17
18
  | Feature | Description |
18
19
  |---------|-------------|
19
- | **Batteries Included** | 42 agents, 52 skills, 22 guides, 18 rules, 1 hook, 1 context - ready to use out of the box |
20
+ | **Batteries Included** | 42 agents, 51 skills, 22 guides, 18 rules, 1 hook, 4 contexts - ready to use out of the box |
20
21
  | **Sub-Agent Model** | Supports hierarchical agent orchestration with specialized roles |
21
22
  | **Dead Simple Customization** | Create a folder + markdown file = new agent or skill |
22
23
  | **Mix and Match** | Use built-in components, create your own, or combine both |
@@ -121,7 +122,7 @@ Claude Code selects the appropriate model and parallelizes independent tasks (up
121
122
  | **QA** | 3 | qa-planner, qa-writer, qa-engineer |
122
123
  | **Total** | **42** | |
123
124
 
124
- ### Skills (52)
125
+ ### Skills (51)
125
126
 
126
127
  Includes slash commands and capabilities:
127
128
 
@@ -157,9 +158,9 @@ Comprehensive reference documentation covering:
157
158
 
158
159
  Event-driven automation for Claude Code lifecycle events (PreToolUse, PostToolUse, etc.).
159
160
 
160
- ### Contexts (1)
161
+ ### Contexts (4)
161
162
 
162
- Shared context file for cross-agent knowledge and mode configurations.
163
+ Shared context files for cross-agent knowledge and mode configurations.
163
164
 
164
165
  ---
165
166
 
@@ -194,13 +195,13 @@ your-project/
194
195
  └── .claude/ # (or .codex/)
195
196
  ├── rules/ # Behavior rules (18 total)
196
197
  ├── hooks/ # Event hooks (1 total)
197
- ├── contexts/ # Context files (1 total)
198
+ ├── contexts/ # Context files (4 total)
198
199
  ├── agents/ # Agent definitions (42 flat .md files)
199
200
  │ ├── lang-golang-expert.md
200
201
  │ ├── be-fastapi-expert.md
201
202
  │ ├── mgr-creator.md
202
203
  │ └── ...
203
- ├── skills/ # Skill modules (52 directories, each with SKILL.md)
204
+ ├── skills/ # Skill modules (51 directories, each with SKILL.md)
204
205
  │ ├── go-best-practices/
205
206
  │ ├── react-best-practices/
206
207
  │ ├── secretary-routing/
@@ -219,6 +220,17 @@ bun test # Run tests
219
220
  bun run build # Build for production
220
221
  ```
221
222
 
223
+ ### Quality Gates
224
+
225
+ | Gate | Tool | Threshold |
226
+ |------|------|-----------|
227
+ | Lint | Biome | Zero errors (complexity enforced) |
228
+ | Test Coverage | Bun test | 95% (pre-commit), 97% (CI) |
229
+ | Security Audit | bun pm audit | No high/critical vulnerabilities |
230
+ | Dependabot | GitHub | Weekly scans, auto-PR for updates |
231
+
232
+ Pre-commit hooks automatically enforce lint, test, and coverage gates before each commit.
233
+
222
234
  ### Requirements
223
235
 
224
236
  - Node.js >= 18.0.0
package/dist/cli/index.js CHANGED
@@ -11725,6 +11725,7 @@ var en_default = {
11725
11725
  skipped: "Update skipped",
11726
11726
  dryRunOption: "Show what would be updated without making changes",
11727
11727
  forceOption: "Force update even if already at latest version",
11728
+ forceOverwriteAllOption: "Bypass all file preservation (manifest and config)",
11728
11729
  backupOption: "Create backup before updating",
11729
11730
  agentsOption: "Update only agents",
11730
11731
  skillsOption: "Update only skills",
@@ -12008,6 +12009,7 @@ var ko_default = {
12008
12009
  skipped: "업데이트 건너뜀",
12009
12010
  dryRunOption: "변경 없이 업데이트할 내용 표시",
12010
12011
  forceOption: "이미 최신 버전이어도 강제 업데이트",
12012
+ forceOverwriteAllOption: "모든 파일 보존 무시 (manifest 및 config)",
12011
12013
  backupOption: "업데이트 전 백업 생성",
12012
12014
  agentsOption: "에이전트만 업데이트",
12013
12015
  skillsOption: "스킬만 업데이트",
@@ -12299,8 +12301,38 @@ var $visitAsync = visit.visitAsync;
12299
12301
  import { join as join2 } from "node:path";
12300
12302
 
12301
12303
  // src/utils/fs.ts
12302
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
12304
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
12303
12305
  import { fileURLToPath } from "node:url";
12306
+ function validatePreserveFilePath(filePath, projectRoot) {
12307
+ if (!filePath || filePath.trim() === "") {
12308
+ return {
12309
+ valid: false,
12310
+ reason: "Path cannot be empty"
12311
+ };
12312
+ }
12313
+ if (isAbsolute(filePath)) {
12314
+ return {
12315
+ valid: false,
12316
+ reason: "Absolute paths are not allowed"
12317
+ };
12318
+ }
12319
+ const normalizedPath = normalize(filePath);
12320
+ if (normalizedPath.startsWith("..")) {
12321
+ return {
12322
+ valid: false,
12323
+ reason: "Path cannot traverse outside project root"
12324
+ };
12325
+ }
12326
+ const resolvedPath = resolve(projectRoot, normalizedPath);
12327
+ const relativePath = relative(projectRoot, resolvedPath);
12328
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
12329
+ return {
12330
+ valid: false,
12331
+ reason: "Resolved path escapes project root"
12332
+ };
12333
+ }
12334
+ return { valid: true };
12335
+ }
12304
12336
  async function fileExists(path) {
12305
12337
  const fs = await import("node:fs/promises");
12306
12338
  try {
@@ -12689,7 +12721,7 @@ async function loadConfig(targetDir) {
12689
12721
  if (await fileExists(configPath)) {
12690
12722
  try {
12691
12723
  const config = await readJsonFile(configPath);
12692
- const merged = mergeConfig(getDefaultConfig(), config);
12724
+ const merged = mergeConfig(getDefaultConfig(), config, targetDir);
12693
12725
  if (merged.configVersion < CURRENT_CONFIG_VERSION) {
12694
12726
  const migrated = migrateConfig(merged);
12695
12727
  await saveConfig(targetDir, migrated);
@@ -12718,7 +12750,30 @@ function deduplicateCustomComponents(components) {
12718
12750
  }
12719
12751
  return [...seen.values()];
12720
12752
  }
12721
- function mergeConfig(defaults, overrides) {
12753
+ function mergeConfig(defaults, overrides, targetDir) {
12754
+ let mergedPreserveFiles;
12755
+ if (overrides.preserveFiles) {
12756
+ const allFiles = [...new Set([...defaults.preserveFiles || [], ...overrides.preserveFiles])];
12757
+ if (targetDir) {
12758
+ const validatedFiles = [];
12759
+ for (const filePath of allFiles) {
12760
+ const validation = validatePreserveFilePath(filePath, targetDir);
12761
+ if (validation.valid) {
12762
+ validatedFiles.push(filePath);
12763
+ } else {
12764
+ warn("config.invalid_preserve_path", {
12765
+ path: filePath,
12766
+ reason: validation.reason ?? "Invalid path"
12767
+ });
12768
+ }
12769
+ }
12770
+ mergedPreserveFiles = validatedFiles;
12771
+ } else {
12772
+ mergedPreserveFiles = allFiles;
12773
+ }
12774
+ } else {
12775
+ mergedPreserveFiles = defaults.preserveFiles;
12776
+ }
12722
12777
  return {
12723
12778
  ...defaults,
12724
12779
  ...overrides,
@@ -12732,7 +12787,7 @@ function mergeConfig(defaults, overrides) {
12732
12787
  ...defaults.agents,
12733
12788
  ...overrides.agents
12734
12789
  },
12735
- preserveFiles: overrides.preserveFiles ? [...new Set([...defaults.preserveFiles || [], ...overrides.preserveFiles])] : defaults.preserveFiles,
12790
+ preserveFiles: mergedPreserveFiles,
12736
12791
  customComponents: overrides.customComponents ? deduplicateCustomComponents([
12737
12792
  ...defaults.customComponents || [],
12738
12793
  ...overrides.customComponents
@@ -13908,13 +13963,13 @@ async function tryExtractMarkdownDescription(mdPath, options = {}) {
13908
13963
  return;
13909
13964
  }
13910
13965
  }
13911
- async function getAgents(targetDir, rootDir = ".claude") {
13966
+ async function getAgents(targetDir, rootDir = ".claude", config) {
13912
13967
  const agentsDir = join6(targetDir, rootDir, "agents");
13913
13968
  if (!await fileExists(agentsDir))
13914
13969
  return [];
13915
13970
  try {
13916
- const config = await loadConfig(targetDir);
13917
- const customComponents = config.customComponents || [];
13971
+ const resolvedConfig = config ?? await loadConfig(targetDir);
13972
+ const customComponents = resolvedConfig.customComponents || [];
13918
13973
  const customAgentPaths = new Set(customComponents.filter((c) => c.type === "agent").map((c) => c.path));
13919
13974
  const agentMdFiles = await listFiles(agentsDir, { recursive: false, pattern: "*.md" });
13920
13975
  const agents = await Promise.all(agentMdFiles.map(async (agentMdPath) => {
@@ -13936,13 +13991,13 @@ async function getAgents(targetDir, rootDir = ".claude") {
13936
13991
  return [];
13937
13992
  }
13938
13993
  }
13939
- async function getSkills(targetDir, rootDir = ".claude") {
13994
+ async function getSkills(targetDir, rootDir = ".claude", config) {
13940
13995
  const skillsDir = join6(targetDir, rootDir, "skills");
13941
13996
  if (!await fileExists(skillsDir))
13942
13997
  return [];
13943
13998
  try {
13944
- const config = await loadConfig(targetDir);
13945
- const customComponents = config.customComponents || [];
13999
+ const resolvedConfig = config ?? await loadConfig(targetDir);
14000
+ const customComponents = resolvedConfig.customComponents || [];
13946
14001
  const customSkillPaths = new Set(customComponents.filter((c) => c.type === "skill").map((c) => c.path));
13947
14002
  const skillMdFiles = await listFiles(skillsDir, { recursive: true, pattern: "SKILL.md" });
13948
14003
  const skills = await Promise.all(skillMdFiles.map(async (skillMdPath) => {
@@ -13965,13 +14020,13 @@ async function getSkills(targetDir, rootDir = ".claude") {
13965
14020
  return [];
13966
14021
  }
13967
14022
  }
13968
- async function getGuides(targetDir) {
14023
+ async function getGuides(targetDir, config) {
13969
14024
  const guidesDir = join6(targetDir, "guides");
13970
14025
  if (!await fileExists(guidesDir))
13971
14026
  return [];
13972
14027
  try {
13973
- const config = await loadConfig(targetDir);
13974
- const customComponents = config.customComponents || [];
14028
+ const resolvedConfig = config ?? await loadConfig(targetDir);
14029
+ const customComponents = resolvedConfig.customComponents || [];
13975
14030
  const customGuidePaths = new Set(customComponents.filter((c) => c.type === "guide").map((c) => c.path));
13976
14031
  const guideMdFiles = await listFiles(guidesDir, { recursive: true, pattern: "*.md" });
13977
14032
  const guides = await Promise.all(guideMdFiles.map(async (guideMdPath) => {
@@ -13992,13 +14047,13 @@ async function getGuides(targetDir) {
13992
14047
  }
13993
14048
  }
13994
14049
  var RULE_PRIORITY_ORDER = { MUST: 0, SHOULD: 1, MAY: 2 };
13995
- async function getRules(targetDir, rootDir = ".claude") {
14050
+ async function getRules(targetDir, rootDir = ".claude", config) {
13996
14051
  const rulesDir = join6(targetDir, rootDir, "rules");
13997
14052
  if (!await fileExists(rulesDir))
13998
14053
  return [];
13999
14054
  try {
14000
- const config = await loadConfig(targetDir);
14001
- const customComponents = config.customComponents || [];
14055
+ const resolvedConfig = config ?? await loadConfig(targetDir);
14056
+ const customComponents = resolvedConfig.customComponents || [];
14002
14057
  const customRulePaths = new Set(customComponents.filter((c) => c.type === "rule").map((c) => c.path));
14003
14058
  const ruleMdFiles = await listFiles(rulesDir, { recursive: false, pattern: "*.md" });
14004
14059
  const rules = await Promise.all(ruleMdFiles.map(async (ruleMdPath) => {
@@ -14108,7 +14163,7 @@ async function getContexts(targetDir, rootDir = ".claude") {
14108
14163
  var COMPONENT_GETTERS = {
14109
14164
  agents: getAgents,
14110
14165
  skills: getSkills,
14111
- guides: async (dir2) => getGuides(dir2),
14166
+ guides: async (dir2, _rootDir, config) => getGuides(dir2, config),
14112
14167
  rules: getRules,
14113
14168
  hooks: getHooks,
14114
14169
  contexts: getContexts
@@ -14122,12 +14177,12 @@ function displayComponents(components, type, format) {
14122
14177
  formatAsTable(components, type);
14123
14178
  }
14124
14179
  }
14125
- async function handleListAll(targetDir, rootDir, format) {
14180
+ async function handleListAll(targetDir, rootDir, format, config) {
14126
14181
  const [agents, skills, guides, rules, hooks, contexts] = await Promise.all([
14127
- getAgents(targetDir, rootDir),
14128
- getSkills(targetDir, rootDir),
14129
- getGuides(targetDir),
14130
- getRules(targetDir, rootDir),
14182
+ getAgents(targetDir, rootDir, config),
14183
+ getSkills(targetDir, rootDir, config),
14184
+ getGuides(targetDir, config),
14185
+ getRules(targetDir, rootDir, config),
14131
14186
  getHooks(targetDir, rootDir),
14132
14187
  getContexts(targetDir, rootDir)
14133
14188
  ]);
@@ -14152,7 +14207,8 @@ async function listCommand(type = "all", options = {}) {
14152
14207
  preferProject: true
14153
14208
  });
14154
14209
  const layout = getProviderLayout(detection.provider);
14155
- const components = type === "all" ? await handleListAll(targetDir, layout.rootDir, format) : await COMPONENT_GETTERS[type](targetDir, layout.rootDir);
14210
+ const config = await loadConfig(targetDir);
14211
+ const components = type === "all" ? await handleListAll(targetDir, layout.rootDir, format, config) : await COMPONENT_GETTERS[type](targetDir, layout.rootDir, config);
14156
14212
  if (type === "all" && format === "json") {
14157
14213
  formatAsJson(components);
14158
14214
  } else if (type !== "all") {
@@ -14172,34 +14228,60 @@ import { join as join7 } from "node:path";
14172
14228
  // src/core/entry-merger.ts
14173
14229
  var MANAGED_START = "<!-- omcustom:start -->";
14174
14230
  var MANAGED_END = "<!-- omcustom:end -->";
14231
+ function isCodeBlockDelimiter(line) {
14232
+ const trimmed = line.trim();
14233
+ return trimmed.startsWith("```") || trimmed.startsWith("~~~");
14234
+ }
14235
+ function handleManagedStart(currentLines, sections) {
14236
+ if (currentLines.length > 0) {
14237
+ sections.push({
14238
+ type: "custom",
14239
+ content: currentLines.join(`
14240
+ `)
14241
+ });
14242
+ }
14243
+ return {
14244
+ currentSection: { type: "managed", content: "" },
14245
+ currentLines: []
14246
+ };
14247
+ }
14248
+ function handleManagedEnd(currentSection, currentLines, sections) {
14249
+ if (currentSection && currentSection.type === "managed") {
14250
+ currentSection.content = currentLines.join(`
14251
+ `);
14252
+ sections.push(currentSection);
14253
+ return {
14254
+ currentSection: null,
14255
+ currentLines: []
14256
+ };
14257
+ }
14258
+ return { currentSection, currentLines };
14259
+ }
14175
14260
  function parseEntryDoc(content) {
14176
14261
  const sections = [];
14177
14262
  const lines = content.split(`
14178
14263
  `);
14179
14264
  let currentSection = null;
14180
14265
  let currentLines = [];
14266
+ let insideCodeBlock = false;
14181
14267
  for (const line of lines) {
14182
- if (line.trim() === MANAGED_START) {
14183
- if (currentLines.length > 0) {
14184
- sections.push({
14185
- type: "custom",
14186
- content: currentLines.join(`
14187
- `)
14188
- });
14189
- currentLines = [];
14268
+ if (isCodeBlockDelimiter(line)) {
14269
+ insideCodeBlock = !insideCodeBlock;
14270
+ }
14271
+ if (!insideCodeBlock) {
14272
+ const trimmed = line.trim();
14273
+ if (trimmed === MANAGED_START) {
14274
+ const result = handleManagedStart(currentLines, sections);
14275
+ currentSection = result.currentSection;
14276
+ currentLines = result.currentLines;
14277
+ continue;
14190
14278
  }
14191
- currentSection = { type: "managed", content: "" };
14192
- continue;
14193
- }
14194
- if (line.trim() === MANAGED_END) {
14195
- if (currentSection && currentSection.type === "managed") {
14196
- currentSection.content = currentLines.join(`
14197
- `);
14198
- sections.push(currentSection);
14199
- currentSection = null;
14200
- currentLines = [];
14279
+ if (trimmed === MANAGED_END) {
14280
+ const result = handleManagedEnd(currentSection, currentLines, sections);
14281
+ currentSection = result.currentSection;
14282
+ currentLines = result.currentLines;
14283
+ continue;
14201
14284
  }
14202
- continue;
14203
14285
  }
14204
14286
  currentLines.push(line);
14205
14287
  }
@@ -14280,7 +14362,7 @@ async function handleBackupIfRequested(targetDir, provider, backup, result) {
14280
14362
  result.backedUpPaths.push(backupPath);
14281
14363
  info("update.backup_created", { path: backupPath });
14282
14364
  }
14283
- async function processComponentUpdate(targetDir, provider, component, updateCheck, customizations, options, result) {
14365
+ async function processComponentUpdate(targetDir, provider, component, updateCheck, customizations, options, result, config) {
14284
14366
  const componentUpdate = updateCheck.updatableComponents.find((c) => c.name === component);
14285
14367
  if (!componentUpdate && !options.force) {
14286
14368
  result.skippedComponents.push(component);
@@ -14292,7 +14374,7 @@ async function processComponentUpdate(targetDir, provider, component, updateChec
14292
14374
  return;
14293
14375
  }
14294
14376
  try {
14295
- const preserved = await updateComponent(targetDir, provider, component, customizations, options);
14377
+ const preserved = await updateComponent(targetDir, provider, component, customizations, options, config);
14296
14378
  result.updatedComponents.push(component);
14297
14379
  result.preservedFiles.push(...preserved);
14298
14380
  } catch (err) {
@@ -14301,9 +14383,9 @@ async function processComponentUpdate(targetDir, provider, component, updateChec
14301
14383
  result.skippedComponents.push(component);
14302
14384
  }
14303
14385
  }
14304
- async function updateAllComponents(targetDir, provider, components, updateCheck, customizations, options, result) {
14386
+ async function updateAllComponents(targetDir, provider, components, updateCheck, customizations, options, result, config) {
14305
14387
  for (const component of components) {
14306
- await processComponentUpdate(targetDir, provider, component, updateCheck, customizations, options, result);
14388
+ await processComponentUpdate(targetDir, provider, component, updateCheck, customizations, options, result, config);
14307
14389
  }
14308
14390
  }
14309
14391
  function getEntryTemplateName2(provider, language) {
@@ -14320,25 +14402,76 @@ async function backupFile(filePath) {
14320
14402
  debug("update.file_backed_up", { path: filePath, backup: backupPath });
14321
14403
  }
14322
14404
  }
14323
- function resolveCustomizations(customizations, configPreserveFiles) {
14324
- if (!customizations && configPreserveFiles.length === 0) {
14405
+ async function resolveManifestCustomizations(options, targetDir) {
14406
+ if (options.forceOverwriteAll) {
14325
14407
  return null;
14326
14408
  }
14327
- if (customizations && configPreserveFiles.length > 0) {
14328
- customizations.preserveFiles = [
14329
- ...new Set([...customizations.preserveFiles, ...configPreserveFiles])
14330
- ];
14331
- return customizations;
14409
+ if (options.preserveCustomizations === false) {
14410
+ return null;
14411
+ }
14412
+ return loadCustomizationManifest(targetDir);
14413
+ }
14414
+ function resolveConfigPreserveFiles(options, config) {
14415
+ if (options.forceOverwriteAll) {
14416
+ return [];
14417
+ }
14418
+ const preserveFiles = config.preserveFiles || [];
14419
+ const validatedPaths = [];
14420
+ for (const filePath of preserveFiles) {
14421
+ const validation = validatePreserveFilePath(filePath, options.targetDir);
14422
+ if (validation.valid) {
14423
+ validatedPaths.push(filePath);
14424
+ } else {
14425
+ warn("preserve_files.invalid_path", {
14426
+ path: filePath,
14427
+ reason: validation.reason ?? "Invalid path"
14428
+ });
14429
+ }
14430
+ }
14431
+ return validatedPaths;
14432
+ }
14433
+ function resolveCustomizations(customizations, configPreserveFiles, targetDir) {
14434
+ const validatedManifestFiles = [];
14435
+ if (customizations && customizations.preserveFiles.length > 0) {
14436
+ for (const filePath of customizations.preserveFiles) {
14437
+ const validation = validatePreserveFilePath(filePath, targetDir);
14438
+ if (validation.valid) {
14439
+ validatedManifestFiles.push(filePath);
14440
+ } else {
14441
+ warn("preserve_files.invalid_path", {
14442
+ path: filePath,
14443
+ reason: validation.reason ?? "Invalid path",
14444
+ source: "manifest"
14445
+ });
14446
+ }
14447
+ }
14448
+ }
14449
+ if (validatedManifestFiles.length === 0 && configPreserveFiles.length === 0) {
14450
+ return customizations && customizations.modifiedFiles.length > 0 ? customizations : null;
14451
+ }
14452
+ if (validatedManifestFiles.length > 0 && configPreserveFiles.length > 0) {
14453
+ const merged = customizations || {
14454
+ modifiedFiles: [],
14455
+ preserveFiles: [],
14456
+ customComponents: [],
14457
+ lastUpdated: new Date().toISOString()
14458
+ };
14459
+ merged.preserveFiles = [...new Set([...validatedManifestFiles, ...configPreserveFiles])];
14460
+ return merged;
14332
14461
  }
14333
14462
  if (configPreserveFiles.length > 0) {
14334
14463
  return {
14335
- modifiedFiles: [],
14464
+ modifiedFiles: customizations?.modifiedFiles || [],
14336
14465
  preserveFiles: configPreserveFiles,
14337
- customComponents: [],
14466
+ customComponents: customizations?.customComponents || [],
14338
14467
  lastUpdated: new Date().toISOString()
14339
14468
  };
14340
14469
  }
14341
- return customizations;
14470
+ if (customizations) {
14471
+ customizations.preserveFiles = validatedManifestFiles;
14472
+ return customizations;
14473
+ }
14474
+ return null;
14342
14475
  }
14343
14476
  async function updateEntryDoc(targetDir, provider, config, options) {
14344
14477
  const layout = getProviderLayout(provider);
@@ -14391,11 +14524,11 @@ async function update(options) {
14391
14524
  return result;
14392
14525
  }
14393
14526
  await handleBackupIfRequested(options.targetDir, provider, !!options.backup, result);
14394
- const manifestCustomizations = options.preserveCustomizations !== false ? await loadCustomizationManifest(options.targetDir) : null;
14395
- const configPreserveFiles = config.preserveFiles || [];
14396
- const customizations = resolveCustomizations(manifestCustomizations, configPreserveFiles);
14527
+ const manifestCustomizations = await resolveManifestCustomizations(options, options.targetDir);
14528
+ const configPreserveFiles = resolveConfigPreserveFiles(options, config);
14529
+ const customizations = resolveCustomizations(manifestCustomizations, configPreserveFiles, options.targetDir);
14397
14530
  const components = options.components || getAllUpdateComponents();
14398
- await updateAllComponents(options.targetDir, provider, components, updateCheck, customizations, options, result);
14531
+ await updateAllComponents(options.targetDir, provider, components, updateCheck, customizations, options, result, config);
14399
14532
  if (!options.components || options.components.length === 0) {
14400
14533
  await updateEntryDoc(options.targetDir, provider, config, options);
14401
14534
  }
@@ -14454,15 +14587,14 @@ async function componentHasUpdate(_targetDir, provider, component, config) {
14454
14587
  const latestVersion = await getLatestVersion(provider);
14455
14588
  return installedVersion !== latestVersion;
14456
14589
  }
14457
- async function updateComponent(targetDir, provider, component, customizations, options) {
14590
+ async function updateComponent(targetDir, provider, component, customizations, options, config) {
14458
14591
  const preservedFiles = [];
14459
14592
  const componentPath = getComponentPath2(provider, component);
14460
14593
  const srcPath = resolveTemplatePath(componentPath);
14461
14594
  const destPath = join7(targetDir, componentPath);
14462
- const config = await loadConfig(targetDir);
14463
14595
  const customComponents = config.customComponents || [];
14464
14596
  const skipPaths = [];
14465
- if (customizations && options.preserveCustomizations !== false) {
14597
+ if (customizations && !options.forceOverwriteAll) {
14466
14598
  const toPreserve = customizations.preserveFiles.filter((f) => f.startsWith(componentPath));
14467
14599
  preservedFiles.push(...toPreserve);
14468
14600
  skipPaths.push(...toPreserve);
@@ -14538,6 +14670,7 @@ async function updateCommand(options = {}) {
14538
14670
  components,
14539
14671
  force: options.force,
14540
14672
  preserveCustomizations: true,
14673
+ forceOverwriteAll: options.forceOverwriteAll,
14541
14674
  dryRun: options.dryRun,
14542
14675
  backup: options.backup
14543
14676
  };
@@ -14611,7 +14744,7 @@ function createProgram() {
14611
14744
  program2.command("init").description(i18n.t("cli.init.description")).option("-l, --lang <language>", i18n.t("cli.init.langOption"), "en").option("-p, --provider <provider>", i18n.t("cli.init.providerOption"), "auto").action(async (options) => {
14612
14745
  await initCommand(options);
14613
14746
  });
14614
- program2.command("update").description(i18n.t("cli.update.description")).option("--dry-run", i18n.t("cli.update.dryRunOption")).option("--force", i18n.t("cli.update.forceOption")).option("--backup", i18n.t("cli.update.backupOption")).option("--agents", i18n.t("cli.update.agentsOption")).option("--skills", i18n.t("cli.update.skillsOption")).option("--rules", i18n.t("cli.update.rulesOption")).option("--guides", i18n.t("cli.update.guidesOption")).option("--hooks", i18n.t("cli.update.hooksOption")).option("--contexts", i18n.t("cli.update.contextsOption")).option("-p, --provider <provider>", i18n.t("cli.update.providerOption"), "auto").action(async (options) => {
14747
+ program2.command("update").description(i18n.t("cli.update.description")).option("--dry-run", i18n.t("cli.update.dryRunOption")).option("--force", i18n.t("cli.update.forceOption")).option("--force-overwrite-all", i18n.t("cli.update.forceOverwriteAllOption")).option("--backup", i18n.t("cli.update.backupOption")).option("--agents", i18n.t("cli.update.agentsOption")).option("--skills", i18n.t("cli.update.skillsOption")).option("--rules", i18n.t("cli.update.rulesOption")).option("--guides", i18n.t("cli.update.guidesOption")).option("--hooks", i18n.t("cli.update.hooksOption")).option("--contexts", i18n.t("cli.update.contextsOption")).option("-p, --provider <provider>", i18n.t("cli.update.providerOption"), "auto").action(async (options) => {
14615
14748
  await updateCommand(options);
14616
14749
  });
14617
14750
  program2.command("list").description(i18n.t("cli.list.description")).argument("[type]", i18n.t("cli.list.typeArgument"), "all").option("-f, --format <format>", "Output format: table, json, or simple", "table").option("--verbose", "Show detailed information").option("-p, --provider <provider>", i18n.t("cli.list.providerOption"), "auto").action(async (type, options) => {
package/dist/index.js CHANGED
@@ -1,28 +1,42 @@
1
1
  import { createRequire } from "node:module";
2
- var __create = Object.create;
3
- var __getProtoOf = Object.getPrototypeOf;
4
- var __defProp = Object.defineProperty;
5
- var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __hasOwnProp = Object.prototype.hasOwnProperty;
7
- var __toESM = (mod, isNodeMode, target) => {
8
- target = mod != null ? __create(__getProtoOf(mod)) : {};
9
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
10
- for (let key of __getOwnPropNames(mod))
11
- if (!__hasOwnProp.call(to, key))
12
- __defProp(to, key, {
13
- get: () => mod[key],
14
- enumerable: true
15
- });
16
- return to;
17
- };
18
2
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
19
3
 
20
4
  // src/core/config.ts
21
5
  import { join as join2 } from "node:path";
22
6
 
23
7
  // src/utils/fs.ts
24
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
25
9
  import { fileURLToPath } from "node:url";
10
+ function validatePreserveFilePath(filePath, projectRoot) {
11
+ if (!filePath || filePath.trim() === "") {
12
+ return {
13
+ valid: false,
14
+ reason: "Path cannot be empty"
15
+ };
16
+ }
17
+ if (isAbsolute(filePath)) {
18
+ return {
19
+ valid: false,
20
+ reason: "Absolute paths are not allowed"
21
+ };
22
+ }
23
+ const normalizedPath = normalize(filePath);
24
+ if (normalizedPath.startsWith("..")) {
25
+ return {
26
+ valid: false,
27
+ reason: "Path cannot traverse outside project root"
28
+ };
29
+ }
30
+ const resolvedPath = resolve(projectRoot, normalizedPath);
31
+ const relativePath = relative(projectRoot, resolvedPath);
32
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
33
+ return {
34
+ valid: false,
35
+ reason: "Resolved path escapes project root"
36
+ };
37
+ }
38
+ return { valid: true };
39
+ }
26
40
  async function fileExists(path) {
27
41
  const fs = await import("node:fs/promises");
28
42
  try {
@@ -402,7 +416,7 @@ async function loadConfig(targetDir) {
402
416
  if (await fileExists(configPath)) {
403
417
  try {
404
418
  const config = await readJsonFile(configPath);
405
- const merged = mergeConfig(getDefaultConfig(), config);
419
+ const merged = mergeConfig(getDefaultConfig(), config, targetDir);
406
420
  if (merged.configVersion < CURRENT_CONFIG_VERSION) {
407
421
  const migrated = migrateConfig(merged);
408
422
  await saveConfig(targetDir, migrated);
@@ -431,7 +445,30 @@ function deduplicateCustomComponents(components) {
431
445
  }
432
446
  return [...seen.values()];
433
447
  }
434
- function mergeConfig(defaults, overrides) {
448
+ function mergeConfig(defaults, overrides, targetDir) {
449
+ let mergedPreserveFiles;
450
+ if (overrides.preserveFiles) {
451
+ const allFiles = [...new Set([...defaults.preserveFiles || [], ...overrides.preserveFiles])];
452
+ if (targetDir) {
453
+ const validatedFiles = [];
454
+ for (const filePath of allFiles) {
455
+ const validation = validatePreserveFilePath(filePath, targetDir);
456
+ if (validation.valid) {
457
+ validatedFiles.push(filePath);
458
+ } else {
459
+ warn("config.invalid_preserve_path", {
460
+ path: filePath,
461
+ reason: validation.reason ?? "Invalid path"
462
+ });
463
+ }
464
+ }
465
+ mergedPreserveFiles = validatedFiles;
466
+ } else {
467
+ mergedPreserveFiles = allFiles;
468
+ }
469
+ } else {
470
+ mergedPreserveFiles = defaults.preserveFiles;
471
+ }
435
472
  return {
436
473
  ...defaults,
437
474
  ...overrides,
@@ -445,7 +482,7 @@ function mergeConfig(defaults, overrides) {
445
482
  ...defaults.agents,
446
483
  ...overrides.agents
447
484
  },
448
- preserveFiles: overrides.preserveFiles ? [...new Set([...defaults.preserveFiles || [], ...overrides.preserveFiles])] : defaults.preserveFiles,
485
+ preserveFiles: mergedPreserveFiles,
449
486
  customComponents: overrides.customComponents ? deduplicateCustomComponents([
450
487
  ...defaults.customComponents || [],
451
488
  ...overrides.customComponents
@@ -904,34 +941,60 @@ import { join as join5 } from "node:path";
904
941
  // src/core/entry-merger.ts
905
942
  var MANAGED_START = "<!-- omcustom:start -->";
906
943
  var MANAGED_END = "<!-- omcustom:end -->";
944
+ function isCodeBlockDelimiter(line) {
945
+ const trimmed = line.trim();
946
+ return trimmed.startsWith("```") || trimmed.startsWith("~~~");
947
+ }
948
+ function handleManagedStart(currentLines, sections) {
949
+ if (currentLines.length > 0) {
950
+ sections.push({
951
+ type: "custom",
952
+ content: currentLines.join(`
953
+ `)
954
+ });
955
+ }
956
+ return {
957
+ currentSection: { type: "managed", content: "" },
958
+ currentLines: []
959
+ };
960
+ }
961
+ function handleManagedEnd(currentSection, currentLines, sections) {
962
+ if (currentSection && currentSection.type === "managed") {
963
+ currentSection.content = currentLines.join(`
964
+ `);
965
+ sections.push(currentSection);
966
+ return {
967
+ currentSection: null,
968
+ currentLines: []
969
+ };
970
+ }
971
+ return { currentSection, currentLines };
972
+ }
907
973
  function parseEntryDoc(content) {
908
974
  const sections = [];
909
975
  const lines = content.split(`
910
976
  `);
911
977
  let currentSection = null;
912
978
  let currentLines = [];
979
+ let insideCodeBlock = false;
913
980
  for (const line of lines) {
914
- if (line.trim() === MANAGED_START) {
915
- if (currentLines.length > 0) {
916
- sections.push({
917
- type: "custom",
918
- content: currentLines.join(`
919
- `)
920
- });
921
- currentLines = [];
922
- }
923
- currentSection = { type: "managed", content: "" };
924
- continue;
981
+ if (isCodeBlockDelimiter(line)) {
982
+ insideCodeBlock = !insideCodeBlock;
925
983
  }
926
- if (line.trim() === MANAGED_END) {
927
- if (currentSection && currentSection.type === "managed") {
928
- currentSection.content = currentLines.join(`
929
- `);
930
- sections.push(currentSection);
931
- currentSection = null;
932
- currentLines = [];
984
+ if (!insideCodeBlock) {
985
+ const trimmed = line.trim();
986
+ if (trimmed === MANAGED_START) {
987
+ const result = handleManagedStart(currentLines, sections);
988
+ currentSection = result.currentSection;
989
+ currentLines = result.currentLines;
990
+ continue;
991
+ }
992
+ if (trimmed === MANAGED_END) {
993
+ const result = handleManagedEnd(currentSection, currentLines, sections);
994
+ currentSection = result.currentSection;
995
+ currentLines = result.currentLines;
996
+ continue;
933
997
  }
934
- continue;
935
998
  }
936
999
  currentLines.push(line);
937
1000
  }
@@ -1012,7 +1075,7 @@ async function handleBackupIfRequested(targetDir, provider, backup, result) {
1012
1075
  result.backedUpPaths.push(backupPath);
1013
1076
  info("update.backup_created", { path: backupPath });
1014
1077
  }
1015
- async function processComponentUpdate(targetDir, provider, component, updateCheck, customizations, options, result) {
1078
+ async function processComponentUpdate(targetDir, provider, component, updateCheck, customizations, options, result, config) {
1016
1079
  const componentUpdate = updateCheck.updatableComponents.find((c) => c.name === component);
1017
1080
  if (!componentUpdate && !options.force) {
1018
1081
  result.skippedComponents.push(component);
@@ -1024,7 +1087,7 @@ async function processComponentUpdate(targetDir, provider, component, updateChec
1024
1087
  return;
1025
1088
  }
1026
1089
  try {
1027
- const preserved = await updateComponent(targetDir, provider, component, customizations, options);
1090
+ const preserved = await updateComponent(targetDir, provider, component, customizations, options, config);
1028
1091
  result.updatedComponents.push(component);
1029
1092
  result.preservedFiles.push(...preserved);
1030
1093
  } catch (err) {
@@ -1033,9 +1096,9 @@ async function processComponentUpdate(targetDir, provider, component, updateChec
1033
1096
  result.skippedComponents.push(component);
1034
1097
  }
1035
1098
  }
1036
- async function updateAllComponents(targetDir, provider, components, updateCheck, customizations, options, result) {
1099
+ async function updateAllComponents(targetDir, provider, components, updateCheck, customizations, options, result, config) {
1037
1100
  for (const component of components) {
1038
- await processComponentUpdate(targetDir, provider, component, updateCheck, customizations, options, result);
1101
+ await processComponentUpdate(targetDir, provider, component, updateCheck, customizations, options, result, config);
1039
1102
  }
1040
1103
  }
1041
1104
  function getEntryTemplateName2(provider, language) {
@@ -1052,25 +1115,76 @@ async function backupFile(filePath) {
1052
1115
  debug("update.file_backed_up", { path: filePath, backup: backupPath });
1053
1116
  }
1054
1117
  }
1055
- function resolveCustomizations(customizations, configPreserveFiles) {
1056
- if (!customizations && configPreserveFiles.length === 0) {
1118
+ async function resolveManifestCustomizations(options, targetDir) {
1119
+ if (options.forceOverwriteAll) {
1057
1120
  return null;
1058
1121
  }
1059
- if (customizations && configPreserveFiles.length > 0) {
1060
- customizations.preserveFiles = [
1061
- ...new Set([...customizations.preserveFiles, ...configPreserveFiles])
1062
- ];
1063
- return customizations;
1122
+ if (options.preserveCustomizations === false) {
1123
+ return null;
1124
+ }
1125
+ return loadCustomizationManifest(targetDir);
1126
+ }
1127
+ function resolveConfigPreserveFiles(options, config) {
1128
+ if (options.forceOverwriteAll) {
1129
+ return [];
1130
+ }
1131
+ const preserveFiles = config.preserveFiles || [];
1132
+ const validatedPaths = [];
1133
+ for (const filePath of preserveFiles) {
1134
+ const validation = validatePreserveFilePath(filePath, options.targetDir);
1135
+ if (validation.valid) {
1136
+ validatedPaths.push(filePath);
1137
+ } else {
1138
+ warn("preserve_files.invalid_path", {
1139
+ path: filePath,
1140
+ reason: validation.reason ?? "Invalid path"
1141
+ });
1142
+ }
1143
+ }
1144
+ return validatedPaths;
1145
+ }
1146
+ function resolveCustomizations(customizations, configPreserveFiles, targetDir) {
1147
+ const validatedManifestFiles = [];
1148
+ if (customizations && customizations.preserveFiles.length > 0) {
1149
+ for (const filePath of customizations.preserveFiles) {
1150
+ const validation = validatePreserveFilePath(filePath, targetDir);
1151
+ if (validation.valid) {
1152
+ validatedManifestFiles.push(filePath);
1153
+ } else {
1154
+ warn("preserve_files.invalid_path", {
1155
+ path: filePath,
1156
+ reason: validation.reason ?? "Invalid path",
1157
+ source: "manifest"
1158
+ });
1159
+ }
1160
+ }
1161
+ }
1162
+ if (validatedManifestFiles.length === 0 && configPreserveFiles.length === 0) {
1163
+ return customizations && customizations.modifiedFiles.length > 0 ? customizations : null;
1164
+ }
1165
+ if (validatedManifestFiles.length > 0 && configPreserveFiles.length > 0) {
1166
+ const merged = customizations || {
1167
+ modifiedFiles: [],
1168
+ preserveFiles: [],
1169
+ customComponents: [],
1170
+ lastUpdated: new Date().toISOString()
1171
+ };
1172
+ merged.preserveFiles = [...new Set([...validatedManifestFiles, ...configPreserveFiles])];
1173
+ return merged;
1064
1174
  }
1065
1175
  if (configPreserveFiles.length > 0) {
1066
1176
  return {
1067
- modifiedFiles: [],
1177
+ modifiedFiles: customizations?.modifiedFiles || [],
1068
1178
  preserveFiles: configPreserveFiles,
1069
- customComponents: [],
1179
+ customComponents: customizations?.customComponents || [],
1070
1180
  lastUpdated: new Date().toISOString()
1071
1181
  };
1072
1182
  }
1073
- return customizations;
1183
+ if (customizations) {
1184
+ customizations.preserveFiles = validatedManifestFiles;
1185
+ return customizations;
1186
+ }
1187
+ return null;
1074
1188
  }
1075
1189
  async function updateEntryDoc(targetDir, provider, config, options) {
1076
1190
  const layout = getProviderLayout(provider);
@@ -1123,11 +1237,11 @@ async function update(options) {
1123
1237
  return result;
1124
1238
  }
1125
1239
  await handleBackupIfRequested(options.targetDir, provider, !!options.backup, result);
1126
- const manifestCustomizations = options.preserveCustomizations !== false ? await loadCustomizationManifest(options.targetDir) : null;
1127
- const configPreserveFiles = config.preserveFiles || [];
1128
- const customizations = resolveCustomizations(manifestCustomizations, configPreserveFiles);
1240
+ const manifestCustomizations = await resolveManifestCustomizations(options, options.targetDir);
1241
+ const configPreserveFiles = resolveConfigPreserveFiles(options, config);
1242
+ const customizations = resolveCustomizations(manifestCustomizations, configPreserveFiles, options.targetDir);
1129
1243
  const components = options.components || getAllUpdateComponents();
1130
- await updateAllComponents(options.targetDir, provider, components, updateCheck, customizations, options, result);
1244
+ await updateAllComponents(options.targetDir, provider, components, updateCheck, customizations, options, result, config);
1131
1245
  if (!options.components || options.components.length === 0) {
1132
1246
  await updateEntryDoc(options.targetDir, provider, config, options);
1133
1247
  }
@@ -1207,15 +1321,14 @@ async function componentHasUpdate(_targetDir, provider, component, config) {
1207
1321
  const latestVersion = await getLatestVersion(provider);
1208
1322
  return installedVersion !== latestVersion;
1209
1323
  }
1210
- async function updateComponent(targetDir, provider, component, customizations, options) {
1324
+ async function updateComponent(targetDir, provider, component, customizations, options, config) {
1211
1325
  const preservedFiles = [];
1212
1326
  const componentPath = getComponentPath2(provider, component);
1213
1327
  const srcPath = resolveTemplatePath(componentPath);
1214
1328
  const destPath = join5(targetDir, componentPath);
1215
- const config = await loadConfig(targetDir);
1216
1329
  const customComponents = config.customComponents || [];
1217
1330
  const skipPaths = [];
1218
- if (customizations && options.preserveCustomizations !== false) {
1331
+ if (customizations && !options.forceOverwriteAll) {
1219
1332
  const toPreserve = customizations.preserveFiles.filter((f) => f.startsWith(componentPath));
1220
1333
  preservedFiles.push(...toPreserve);
1221
1334
  skipPaths.push(...toPreserve);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-customcode",
3
- "version": "0.9.4",
3
+ "version": "0.10.1",
4
4
  "description": "Batteries-included agent harness for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,104 +25,22 @@ public class UserService {
25
25
  ### 3. REST API Design
26
26
  @RestController + @RequestMapping. Use @Validated for input, ResponseEntity for responses, proper HTTP status codes.
27
27
 
28
- ```java
29
- @RestController
30
- @RequestMapping("/api/v1/users")
31
- @RequiredArgsConstructor
32
- public class UserController {
33
- private final UserService userService;
34
-
35
- @GetMapping("/{id}")
36
- public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
37
- return ResponseEntity.ok(userService.findById(id));
38
- }
39
-
40
- @PostMapping
41
- @ResponseStatus(HttpStatus.CREATED)
42
- public UserResponse createUser(@Valid @RequestBody UserRequest request) {
43
- return userService.create(request);
44
- }
45
- }
46
- ```
28
+ See `examples/controller-example.java` for reference implementation.
47
29
 
48
30
  ### 4. Service Layer
49
31
  Business logic in services. @Transactional boundaries at service level. Interface + implementation pattern.
50
32
 
51
- ```java
52
- @Service
53
- @Transactional(readOnly = true)
54
- @RequiredArgsConstructor
55
- public class UserServiceImpl implements UserService {
56
- private final UserRepository userRepository;
57
-
58
- @Override
59
- public UserResponse findById(Long id) {
60
- User user = userRepository.findById(id)
61
- .orElseThrow(() -> new UserNotFoundException(id));
62
- return userMapper.toResponse(user);
63
- }
64
-
65
- @Override
66
- @Transactional
67
- public UserResponse create(UserRequest request) {
68
- User user = userMapper.toEntity(request);
69
- return userMapper.toResponse(userRepository.save(user));
70
- }
71
- }
72
- ```
33
+ See `examples/service-example.java` for reference implementation.
73
34
 
74
35
  ### 5. Data Access
75
36
  Spring Data JPA. @Query or method naming for custom queries. @Entity with proper JPA annotations.
76
37
 
77
- ```java
78
- public interface UserRepository extends JpaRepository<User, Long> {
79
- Optional<User> findByEmail(String email);
80
-
81
- @Query("SELECT u FROM User u WHERE u.status = :status")
82
- List<User> findByStatus(@Param("status") UserStatus status);
83
- }
84
-
85
- @Entity
86
- @Table(name = "users")
87
- @Getter
88
- @NoArgsConstructor(access = AccessLevel.PROTECTED)
89
- public class User {
90
- @Id
91
- @GeneratedValue(strategy = GenerationType.IDENTITY)
92
- private Long id;
93
-
94
- @Column(nullable = false, unique = true)
95
- private String email;
96
-
97
- @Enumerated(EnumType.STRING)
98
- private UserStatus status;
99
- }
100
- ```
38
+ See `examples/repository-example.java` and `examples/entity-example.java` for reference implementations.
101
39
 
102
40
  ### 6. Exception Handling
103
41
  @RestControllerAdvice for global handling. Domain-specific exceptions with proper HTTP status mapping.
104
42
 
105
- ```java
106
- @RestControllerAdvice
107
- public class GlobalExceptionHandler {
108
- @ExceptionHandler(UserNotFoundException.class)
109
- @ResponseStatus(HttpStatus.NOT_FOUND)
110
- public ErrorResponse handleUserNotFound(UserNotFoundException ex) {
111
- return new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
112
- }
113
-
114
- @ExceptionHandler(MethodArgumentNotValidException.class)
115
- @ResponseStatus(HttpStatus.BAD_REQUEST)
116
- public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
117
- List<String> errors = ex.getBindingResult()
118
- .getFieldErrors()
119
- .stream()
120
- .map(e -> e.getField() + ": " + e.getDefaultMessage())
121
- .toList();
122
- return new ErrorResponse("VALIDATION_ERROR", errors);
123
- }
124
- }
125
- ```
43
+ See `examples/exception-handler-example.java` for reference implementation.
126
44
 
127
45
  ### 7. Configuration
128
46
  Profile-based: application-{profile}.yml. @ConfigurationProperties for type-safe config. Externalize sensitive values.
@@ -138,80 +56,17 @@ spring:
138
56
  password: ${DATABASE_PASSWORD}
139
57
  ```
140
58
 
141
- ```java
142
- @Configuration
143
- @ConfigurationProperties(prefix = "app")
144
- @Validated
145
- public class AppProperties {
146
- @NotBlank
147
- private String name;
148
-
149
- @Min(1)
150
- private int maxConnections;
151
- }
152
- ```
59
+ See `examples/config-properties-example.java` for type-safe configuration properties.
153
60
 
154
61
  ### 8. Security
155
62
  Spring Security with SecurityFilterChain. Externalize secrets. Proper authentication/authorization patterns.
156
63
 
157
- ```java
158
- @Configuration
159
- @EnableWebSecurity
160
- @RequiredArgsConstructor
161
- public class SecurityConfig {
162
- @Bean
163
- public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
164
- return http
165
- .csrf(csrf -> csrf.disable())
166
- .sessionManagement(session ->
167
- session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
168
- .authorizeHttpRequests(auth -> auth
169
- .requestMatchers("/api/v1/auth/**").permitAll()
170
- .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
171
- .anyRequest().authenticated())
172
- .build();
173
- }
174
- }
175
- ```
64
+ See `examples/security-config-example.java` for reference implementation.
176
65
 
177
66
  ### 9. Testing
178
67
  @WebMvcTest (controller), @DataJpaTest (repository), @SpringBootTest (integration), @MockBean for mocking.
179
68
 
180
- ```java
181
- // Controller test
182
- @WebMvcTest(UserController.class)
183
- class UserControllerTest {
184
- @Autowired
185
- private MockMvc mockMvc;
186
-
187
- @MockBean
188
- private UserService userService;
189
-
190
- @Test
191
- void getUser_shouldReturnUser() throws Exception {
192
- given(userService.findById(1L))
193
- .willReturn(new UserResponse(1L, "test@example.com"));
194
-
195
- mockMvc.perform(get("/api/v1/users/1"))
196
- .andExpect(status().isOk())
197
- .andExpect(jsonPath("$.email").value("test@example.com"));
198
- }
199
- }
200
-
201
- // Repository test
202
- @DataJpaTest
203
- class UserRepositoryTest {
204
- @Autowired
205
- private UserRepository userRepository;
206
-
207
- @Test
208
- void findByEmail_shouldReturnUser() {
209
- User user = userRepository.save(new User("test@example.com"));
210
- Optional<User> found = userRepository.findByEmail("test@example.com");
211
- assertThat(found).isPresent();
212
- }
213
- }
214
- ```
69
+ See `examples/controller-test-example.java` and `examples/repository-test-example.java` for reference implementations.
215
70
 
216
71
  ## Application
217
72
 
@@ -0,0 +1,22 @@
1
+ package com.example.demo.config;
2
+
3
+ import jakarta.validation.constraints.Min;
4
+ import jakarta.validation.constraints.NotBlank;
5
+ import lombok.Getter;
6
+ import lombok.Setter;
7
+ import org.springframework.boot.context.properties.ConfigurationProperties;
8
+ import org.springframework.context.annotation.Configuration;
9
+ import org.springframework.validation.annotation.Validated;
10
+
11
+ @Configuration
12
+ @ConfigurationProperties(prefix = "app")
13
+ @Validated
14
+ @Getter
15
+ @Setter
16
+ public class AppProperties {
17
+ @NotBlank
18
+ private String name;
19
+
20
+ @Min(1)
21
+ private int maxConnections;
22
+ }
@@ -0,0 +1,28 @@
1
+ package com.example.demo.controller;
2
+
3
+ import com.example.demo.dto.UserRequest;
4
+ import com.example.demo.dto.UserResponse;
5
+ import com.example.demo.service.UserService;
6
+ import jakarta.validation.Valid;
7
+ import lombok.RequiredArgsConstructor;
8
+ import org.springframework.http.HttpStatus;
9
+ import org.springframework.http.ResponseEntity;
10
+ import org.springframework.web.bind.annotation.*;
11
+
12
+ @RestController
13
+ @RequestMapping("/api/v1/users")
14
+ @RequiredArgsConstructor
15
+ public class UserController {
16
+ private final UserService userService;
17
+
18
+ @GetMapping("/{id}")
19
+ public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
20
+ return ResponseEntity.ok(userService.findById(id));
21
+ }
22
+
23
+ @PostMapping
24
+ @ResponseStatus(HttpStatus.CREATED)
25
+ public UserResponse createUser(@Valid @RequestBody UserRequest request) {
26
+ return userService.create(request);
27
+ }
28
+ }
@@ -0,0 +1,33 @@
1
+ package com.example.demo.controller;
2
+
3
+ import com.example.demo.dto.UserResponse;
4
+ import com.example.demo.service.UserService;
5
+ import org.junit.jupiter.api.Test;
6
+ import org.springframework.beans.factory.annotation.Autowired;
7
+ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
8
+ import org.springframework.boot.test.mock.mockito.MockBean;
9
+ import org.springframework.test.web.servlet.MockMvc;
10
+
11
+ import static org.mockito.BDDMockito.given;
12
+ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
13
+ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
14
+ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
15
+
16
+ @WebMvcTest(UserController.class)
17
+ class UserControllerTest {
18
+ @Autowired
19
+ private MockMvc mockMvc;
20
+
21
+ @MockBean
22
+ private UserService userService;
23
+
24
+ @Test
25
+ void getUser_shouldReturnUser() throws Exception {
26
+ given(userService.findById(1L))
27
+ .willReturn(new UserResponse(1L, "test@example.com"));
28
+
29
+ mockMvc.perform(get("/api/v1/users/1"))
30
+ .andExpect(status().isOk())
31
+ .andExpect(jsonPath("$.email").value("test@example.com"));
32
+ }
33
+ }
@@ -0,0 +1,22 @@
1
+ package com.example.demo.entity;
2
+
3
+ import jakarta.persistence.*;
4
+ import lombok.AccessLevel;
5
+ import lombok.Getter;
6
+ import lombok.NoArgsConstructor;
7
+
8
+ @Entity
9
+ @Table(name = "users")
10
+ @Getter
11
+ @NoArgsConstructor(access = AccessLevel.PROTECTED)
12
+ public class User {
13
+ @Id
14
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
15
+ private Long id;
16
+
17
+ @Column(nullable = false, unique = true)
18
+ private String email;
19
+
20
+ @Enumerated(EnumType.STRING)
21
+ private UserStatus status;
22
+ }
@@ -0,0 +1,30 @@
1
+ package com.example.demo.exception;
2
+
3
+ import com.example.demo.dto.ErrorResponse;
4
+ import org.springframework.http.HttpStatus;
5
+ import org.springframework.web.bind.MethodArgumentNotValidException;
6
+ import org.springframework.web.bind.annotation.ExceptionHandler;
7
+ import org.springframework.web.bind.annotation.ResponseStatus;
8
+ import org.springframework.web.bind.annotation.RestControllerAdvice;
9
+
10
+ import java.util.List;
11
+
12
+ @RestControllerAdvice
13
+ public class GlobalExceptionHandler {
14
+ @ExceptionHandler(UserNotFoundException.class)
15
+ @ResponseStatus(HttpStatus.NOT_FOUND)
16
+ public ErrorResponse handleUserNotFound(UserNotFoundException ex) {
17
+ return new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
18
+ }
19
+
20
+ @ExceptionHandler(MethodArgumentNotValidException.class)
21
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
22
+ public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
23
+ List<String> errors = ex.getBindingResult()
24
+ .getFieldErrors()
25
+ .stream()
26
+ .map(e -> e.getField() + ": " + e.getDefaultMessage())
27
+ .toList();
28
+ return new ErrorResponse("VALIDATION_ERROR", errors);
29
+ }
30
+ }
@@ -0,0 +1,17 @@
1
+ package com.example.demo.repository;
2
+
3
+ import com.example.demo.entity.User;
4
+ import com.example.demo.entity.UserStatus;
5
+ import org.springframework.data.jpa.repository.JpaRepository;
6
+ import org.springframework.data.jpa.repository.Query;
7
+ import org.springframework.data.repository.query.Param;
8
+
9
+ import java.util.List;
10
+ import java.util.Optional;
11
+
12
+ public interface UserRepository extends JpaRepository<User, Long> {
13
+ Optional<User> findByEmail(String email);
14
+
15
+ @Query("SELECT u FROM User u WHERE u.status = :status")
16
+ List<User> findByStatus(@Param("status") UserStatus status);
17
+ }
@@ -0,0 +1,23 @@
1
+ package com.example.demo.repository;
2
+
3
+ import com.example.demo.entity.User;
4
+ import org.junit.jupiter.api.Test;
5
+ import org.springframework.beans.factory.annotation.Autowired;
6
+ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
7
+
8
+ import java.util.Optional;
9
+
10
+ import static org.assertj.core.api.Assertions.assertThat;
11
+
12
+ @DataJpaTest
13
+ class UserRepositoryTest {
14
+ @Autowired
15
+ private UserRepository userRepository;
16
+
17
+ @Test
18
+ void findByEmail_shouldReturnUser() {
19
+ User user = userRepository.save(new User("test@example.com"));
20
+ Optional<User> found = userRepository.findByEmail("test@example.com");
21
+ assertThat(found).isPresent();
22
+ }
23
+ }
@@ -0,0 +1,27 @@
1
+ package com.example.demo.config;
2
+
3
+ import lombok.RequiredArgsConstructor;
4
+ import org.springframework.context.annotation.Bean;
5
+ import org.springframework.context.annotation.Configuration;
6
+ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
7
+ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
8
+ import org.springframework.security.config.http.SessionCreationPolicy;
9
+ import org.springframework.security.web.SecurityFilterChain;
10
+
11
+ @Configuration
12
+ @EnableWebSecurity
13
+ @RequiredArgsConstructor
14
+ public class SecurityConfig {
15
+ @Bean
16
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
17
+ return http
18
+ .csrf(csrf -> csrf.disable())
19
+ .sessionManagement(session ->
20
+ session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
21
+ .authorizeHttpRequests(auth -> auth
22
+ .requestMatchers("/api/v1/auth/**").permitAll()
23
+ .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
24
+ .anyRequest().authenticated())
25
+ .build();
26
+ }
27
+ }
@@ -0,0 +1,33 @@
1
+ package com.example.demo.service;
2
+
3
+ import com.example.demo.dto.UserRequest;
4
+ import com.example.demo.dto.UserResponse;
5
+ import com.example.demo.entity.User;
6
+ import com.example.demo.exception.UserNotFoundException;
7
+ import com.example.demo.mapper.UserMapper;
8
+ import com.example.demo.repository.UserRepository;
9
+ import lombok.RequiredArgsConstructor;
10
+ import org.springframework.stereotype.Service;
11
+ import org.springframework.transaction.annotation.Transactional;
12
+
13
+ @Service
14
+ @Transactional(readOnly = true)
15
+ @RequiredArgsConstructor
16
+ public class UserServiceImpl implements UserService {
17
+ private final UserRepository userRepository;
18
+ private final UserMapper userMapper;
19
+
20
+ @Override
21
+ public UserResponse findById(Long id) {
22
+ User user = userRepository.findById(id)
23
+ .orElseThrow(() -> new UserNotFoundException(id));
24
+ return userMapper.toResponse(user);
25
+ }
26
+
27
+ @Override
28
+ @Transactional
29
+ public UserResponse create(UserRequest request) {
30
+ User user = userMapper.toEntity(request);
31
+ return userMapper.toResponse(userRepository.save(user));
32
+ }
33
+ }