@hi-man/himan 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,13 @@ import { ResourceScanner } from "../resource/resource-scanner.js";
3
3
  import semver from "semver";
4
4
  import { HimanError, errorCodes } from "../../utils/errors.js";
5
5
  import { promises as fs } from "node:fs";
6
+ import { createHash } from "node:crypto";
6
7
  import path from "node:path";
7
8
  import YAML from "yaml";
8
9
  import { IndexCacheStore } from "../../state/index-cache-store.js";
10
+ const RESOURCE_TYPES = ["rule", "command", "skill"];
11
+ const README_RESOURCES_START = "<!-- himan:resources:start -->";
12
+ const README_RESOURCES_END = "<!-- himan:resources:end -->";
9
13
  export class GitSourceAdapter {
10
14
  repoManager = new RepoManager();
11
15
  scanner = new ResourceScanner();
@@ -23,13 +27,13 @@ export class GitSourceAdapter {
23
27
  const repoId = this.sourceConfig?.repoId ?? "default";
24
28
  const typeDir = this.getTypeDir(type);
25
29
  const baseDir = path.join(repoDir, typeDir);
26
- const baseDirMtimeMs = await this.getMtimeMs(baseDir);
30
+ const metadataHash = await this.getResourceMetadataHash(baseDir);
27
31
  const cached = await this.indexStore.get(repoId, type);
28
- if (cached && cached.baseDirMtimeMs === baseDirMtimeMs) {
32
+ if (cached && cached.metadataHash === metadataHash) {
29
33
  return cached.resources;
30
34
  }
31
35
  const scanned = await this.scanner.scanByType(repoDir, type);
32
- await this.indexStore.upsert(repoId, type, baseDirMtimeMs, scanned);
36
+ await this.indexStore.upsert(repoId, type, metadataHash, scanned);
33
37
  return scanned;
34
38
  }
35
39
  async history(type, name) {
@@ -47,6 +51,7 @@ export class GitSourceAdapter {
47
51
  async publish(type, name, version, sourceDir) {
48
52
  const repoDir = this.getRepoDir();
49
53
  const targetDir = path.join(repoDir, `${type}s`, name);
54
+ const metadata = await this.validatePublishResource(type, name, sourceDir);
50
55
  const sameDir = await this.isSameDirectory(sourceDir, targetDir);
51
56
  if (!sameDir) {
52
57
  await fs.rm(targetDir, { recursive: true, force: true });
@@ -54,14 +59,17 @@ export class GitSourceAdapter {
54
59
  await fs.cp(sourceDir, targetDir, { recursive: true });
55
60
  }
56
61
  const yamlPath = path.join(targetDir, "himan.yaml");
57
- if (await this.exists(yamlPath)) {
58
- const raw = await fs.readFile(yamlPath, "utf8");
59
- const parsed = YAML.parse(raw);
60
- parsed.version = version;
61
- await fs.writeFile(yamlPath, YAML.stringify(parsed), "utf8");
62
- }
62
+ metadata.version = version;
63
+ await fs.writeFile(yamlPath, YAML.stringify(metadata), "utf8");
64
+ const docsPaths = await this.maintainSourceDocs(repoDir, {
65
+ section: "Changed",
66
+ line: `- Published \`${type}/${name}@${version}\`.`,
67
+ });
63
68
  const tag = `${type}/${name}@${version}`;
64
- await this.repoManager.commitTagAndPush(repoDir, `publish ${type}/${name}@${version}`, tag);
69
+ await this.repoManager.commitTagAndPush(repoDir, `publish ${type}/${name}@${version}`, tag, undefined, [
70
+ path.relative(repoDir, targetDir),
71
+ ...docsPaths.map((docPath) => path.relative(repoDir, docPath)),
72
+ ]);
65
73
  return { version, tag };
66
74
  }
67
75
  async create(type, name, options) {
@@ -69,7 +77,8 @@ export class GitSourceAdapter {
69
77
  const resourceDir = path.join(repoDir, this.getTypeDir(type), name);
70
78
  const entry = options.entry ?? this.getDefaultEntry(type);
71
79
  const agents = options.agents?.length ? options.agents : ["cursor"];
72
- if ((await this.exists(resourceDir)) && !options.force) {
80
+ const resourceExists = await this.exists(resourceDir);
81
+ if (resourceExists && !options.force) {
73
82
  throw new HimanError(errorCodes.RESOURCE_EXISTS, `Resource already exists: ${type}/${name}`);
74
83
  }
75
84
  const files = [path.join(resourceDir, "himan.yaml"), path.join(resourceDir, entry)];
@@ -85,6 +94,12 @@ export class GitSourceAdapter {
85
94
  agents,
86
95
  }), "utf8");
87
96
  await fs.writeFile(path.join(resourceDir, entry), this.getDefaultContent(type, name), "utf8");
97
+ await this.maintainSourceDocs(repoDir, {
98
+ section: resourceExists ? "Changed" : "Added",
99
+ line: resourceExists
100
+ ? `- Updated \`${type}/${name}\`.`
101
+ : `- Added \`${type}/${name}\`.`,
102
+ });
88
103
  }
89
104
  return {
90
105
  type,
@@ -94,6 +109,34 @@ export class GitSourceAdapter {
94
109
  dryRun: Boolean(options.dryRun),
95
110
  };
96
111
  }
112
+ async initDocs(options = {}) {
113
+ const repoDir = this.getRepoDir();
114
+ const files = [
115
+ {
116
+ path: path.join(repoDir, "README.md"),
117
+ content: await this.buildReadmeContent(repoDir),
118
+ },
119
+ {
120
+ path: path.join(repoDir, "CHANGELOG.md"),
121
+ content: this.buildChangelogContent(),
122
+ },
123
+ ];
124
+ const results = [];
125
+ for (const file of files) {
126
+ const exists = await this.exists(file.path);
127
+ const action = exists ? (options.force ? "updated" : "skipped") : "created";
128
+ const reason = action === "skipped" ? "file already exists" : undefined;
129
+ results.push({ path: file.path, action, reason });
130
+ if (!options.dryRun && action !== "skipped") {
131
+ await fs.writeFile(file.path, file.content, "utf8");
132
+ }
133
+ }
134
+ return {
135
+ sourceDir: repoDir,
136
+ files: results,
137
+ dryRun: Boolean(options.dryRun),
138
+ };
139
+ }
97
140
  getRepoDir() {
98
141
  if (!this.sourceConfig?.repoDir) {
99
142
  throw new HimanError(errorCodes.CONFIG_NOT_FOUND, "Git source is not initialized.");
@@ -118,14 +161,66 @@ export class GitSourceAdapter {
118
161
  return false;
119
162
  }
120
163
  }
121
- async getMtimeMs(targetPath) {
164
+ async validatePublishResource(type, name, resourceDir) {
165
+ const yamlPath = path.join(resourceDir, "himan.yaml");
166
+ if (!(await this.exists(yamlPath))) {
167
+ throw this.invalidResourceMetadata(type, name, "Missing himan.yaml for publish.", { yamlPath });
168
+ }
169
+ const raw = await fs.readFile(yamlPath, "utf8");
170
+ let parsed;
122
171
  try {
123
- const stat = await fs.stat(targetPath);
124
- return stat.mtimeMs;
172
+ parsed = YAML.parse(raw);
125
173
  }
126
- catch {
127
- return 0;
174
+ catch (error) {
175
+ throw this.invalidResourceMetadata(type, name, "himan.yaml is not valid YAML.", { yamlPath, reason: error instanceof Error ? error.message : String(error) });
176
+ }
177
+ if (!this.isRecord(parsed)) {
178
+ throw this.invalidResourceMetadata(type, name, "himan.yaml must be an object.", { yamlPath });
179
+ }
180
+ if (parsed.name !== name) {
181
+ throw this.invalidResourceMetadata(type, name, `himan.yaml name must be "${name}".`, { yamlPath, actual: parsed.name });
182
+ }
183
+ if (parsed.type !== type) {
184
+ throw this.invalidResourceMetadata(type, name, `himan.yaml type must be "${type}".`, { yamlPath, actual: parsed.type });
185
+ }
186
+ if (typeof parsed.entry !== "string" || parsed.entry.trim().length === 0) {
187
+ throw this.invalidResourceMetadata(type, name, "himan.yaml entry is required.", { yamlPath });
188
+ }
189
+ const entry = parsed.entry.trim();
190
+ const entryPath = path.resolve(resourceDir, entry);
191
+ const resourceRoot = path.resolve(resourceDir);
192
+ const relativeEntryPath = path.relative(resourceRoot, entryPath);
193
+ if (path.isAbsolute(entry) ||
194
+ relativeEntryPath === "" ||
195
+ relativeEntryPath.startsWith("..") ||
196
+ path.isAbsolute(relativeEntryPath)) {
197
+ throw this.invalidResourceMetadata(type, name, "himan.yaml entry must point to a file inside the resource directory.", { yamlPath, entry });
128
198
  }
199
+ let entryStat;
200
+ try {
201
+ entryStat = await fs.stat(entryPath);
202
+ }
203
+ catch (error) {
204
+ if (!this.isNotFoundError(error)) {
205
+ throw error;
206
+ }
207
+ throw this.invalidResourceMetadata(type, name, `Resource entry file not found: ${entry}`, { yamlPath, entry, entryPath });
208
+ }
209
+ if (!entryStat.isFile()) {
210
+ throw this.invalidResourceMetadata(type, name, `Resource entry is not a file: ${entry}`, { yamlPath, entry, entryPath });
211
+ }
212
+ return {
213
+ ...parsed,
214
+ name,
215
+ type,
216
+ entry,
217
+ };
218
+ }
219
+ invalidResourceMetadata(type, name, message, details) {
220
+ return new HimanError(errorCodes.INVALID_RESOURCE_METADATA, `Invalid metadata for ${type}/${name}: ${message}`, details);
221
+ }
222
+ isRecord(value) {
223
+ return typeof value === "object" && value !== null && !Array.isArray(value);
129
224
  }
130
225
  getTypeDir(type) {
131
226
  if (type === "rule")
@@ -134,6 +229,42 @@ export class GitSourceAdapter {
134
229
  return "commands";
135
230
  return "skills";
136
231
  }
232
+ async getResourceMetadataHash(baseDir) {
233
+ const hash = createHash("sha256");
234
+ hash.update("himan-resource-index-v1");
235
+ if (!(await this.exists(baseDir))) {
236
+ hash.update("\0missing");
237
+ return hash.digest("hex");
238
+ }
239
+ const entries = await fs.readdir(baseDir, { withFileTypes: true });
240
+ const resourceDirNames = entries
241
+ .filter((entry) => entry.isDirectory())
242
+ .map((entry) => entry.name)
243
+ .sort();
244
+ for (const resourceDirName of resourceDirNames) {
245
+ hash.update("\0dir:");
246
+ hash.update(resourceDirName);
247
+ const yamlPath = path.join(baseDir, resourceDirName, "himan.yaml");
248
+ try {
249
+ const raw = await fs.readFile(yamlPath);
250
+ hash.update("\0yaml:");
251
+ hash.update(raw);
252
+ }
253
+ catch (error) {
254
+ if (!this.isNotFoundError(error)) {
255
+ throw error;
256
+ }
257
+ hash.update("\0yaml-missing");
258
+ }
259
+ }
260
+ return hash.digest("hex");
261
+ }
262
+ isNotFoundError(error) {
263
+ return (typeof error === "object" &&
264
+ error !== null &&
265
+ "code" in error &&
266
+ error.code === "ENOENT");
267
+ }
137
268
  getDefaultEntry(type) {
138
269
  return type === "skill" ? "SKILL.md" : "content.md";
139
270
  }
@@ -146,4 +277,200 @@ export class GitSourceAdapter {
146
277
  }
147
278
  return `# ${name}\n\nDescribe skill workflow here.\n`;
148
279
  }
280
+ async buildReadmeContent(repoDir) {
281
+ const resourceLines = await this.buildResourceIndex(repoDir);
282
+ const repo = this.sourceConfig?.repo ?? "<git_url>";
283
+ return [
284
+ `# ${this.getSourceTitle()}`,
285
+ "",
286
+ "Himan source repository for reusable agent resources.",
287
+ "",
288
+ "## Resources",
289
+ "",
290
+ README_RESOURCES_START,
291
+ ...resourceLines,
292
+ README_RESOURCES_END,
293
+ "",
294
+ "## Usage",
295
+ "",
296
+ "```bash",
297
+ `himan source add team ${repo}`,
298
+ "himan source use team",
299
+ "himan list rule",
300
+ "himan install rule <name>",
301
+ "```",
302
+ "",
303
+ "## Maintenance",
304
+ "",
305
+ "- Add resources with `himan create <type> <name>`.",
306
+ "- Publish resource versions with `himan publish <type> <name>`.",
307
+ "- Record source-level changes in `CHANGELOG.md`.",
308
+ "- Resource versions are tracked by Git tags such as `rule/code-review@1.0.0`.",
309
+ "",
310
+ ].join("\n");
311
+ }
312
+ buildChangelogContent() {
313
+ return [
314
+ "# Changelog",
315
+ "",
316
+ "All notable source-level resource changes are documented in this file.",
317
+ "",
318
+ "## [Unreleased]",
319
+ "",
320
+ "### Added",
321
+ "",
322
+ "- Initial source README/CHANGELOG scaffold.",
323
+ "",
324
+ ].join("\n");
325
+ }
326
+ async buildResourceIndex(repoDir) {
327
+ const sections = [];
328
+ for (const type of RESOURCE_TYPES) {
329
+ const resources = (await this.scanner.scanByType(repoDir, type)).sort((a, b) => a.name.localeCompare(b.name));
330
+ sections.push(`### ${this.getTypeLabel(type)}`, "");
331
+ if (resources.length === 0) {
332
+ sections.push(`- No ${type} resources yet.`, "");
333
+ continue;
334
+ }
335
+ for (const resource of resources) {
336
+ const version = await this.readResourceVersion(repoDir, resource.type, resource.name);
337
+ const ref = version
338
+ ? `${resource.type}/${resource.name}@${version}`
339
+ : `${resource.type}/${resource.name}`;
340
+ sections.push(`- \`${ref}\`${resource.description ? `: ${resource.description}` : ""}`);
341
+ }
342
+ sections.push("");
343
+ }
344
+ while (sections.at(-1) === "") {
345
+ sections.pop();
346
+ }
347
+ return sections;
348
+ }
349
+ async maintainSourceDocs(repoDir, changelogEntry) {
350
+ const readmePath = await this.updateReadmeResourceIndex(repoDir);
351
+ const changelogPath = await this.updateChangelog(repoDir, changelogEntry);
352
+ return [readmePath, changelogPath];
353
+ }
354
+ async updateReadmeResourceIndex(repoDir) {
355
+ const readmePath = path.join(repoDir, "README.md");
356
+ if (!(await this.exists(readmePath))) {
357
+ await fs.writeFile(readmePath, await this.buildReadmeContent(repoDir), "utf8");
358
+ return readmePath;
359
+ }
360
+ const current = await fs.readFile(readmePath, "utf8");
361
+ const resourceSection = [
362
+ README_RESOURCES_START,
363
+ ...(await this.buildResourceIndex(repoDir)),
364
+ README_RESOURCES_END,
365
+ ].join("\n");
366
+ const updated = this.replaceOrAppendReadmeResourceSection(current, resourceSection);
367
+ if (updated !== current) {
368
+ await fs.writeFile(readmePath, updated, "utf8");
369
+ }
370
+ return readmePath;
371
+ }
372
+ replaceOrAppendReadmeResourceSection(content, resourceSection) {
373
+ const startIndex = content.indexOf(README_RESOURCES_START);
374
+ const endIndex = content.indexOf(README_RESOURCES_END);
375
+ if (startIndex >= 0 && endIndex > startIndex) {
376
+ const before = content.slice(0, startIndex).replace(/\s*$/, "\n");
377
+ const after = content
378
+ .slice(endIndex + README_RESOURCES_END.length)
379
+ .replace(/^\s*/, "\n\n");
380
+ return `${before}${resourceSection}${after}`.replace(/\s*$/, "\n");
381
+ }
382
+ const base = content.replace(/\s*$/, "");
383
+ return `${base}\n\n## Resources\n\n${resourceSection}\n`;
384
+ }
385
+ async updateChangelog(repoDir, entry) {
386
+ const changelogPath = path.join(repoDir, "CHANGELOG.md");
387
+ const current = (await this.exists(changelogPath))
388
+ ? await fs.readFile(changelogPath, "utf8")
389
+ : this.buildChangelogBaseContent();
390
+ const updated = this.insertChangelogEntry(current, entry);
391
+ if (updated !== current) {
392
+ await fs.writeFile(changelogPath, updated, "utf8");
393
+ }
394
+ return changelogPath;
395
+ }
396
+ buildChangelogBaseContent() {
397
+ return [
398
+ "# Changelog",
399
+ "",
400
+ "All notable source-level resource changes are documented in this file.",
401
+ "",
402
+ "## [Unreleased]",
403
+ "",
404
+ ].join("\n");
405
+ }
406
+ insertChangelogEntry(content, entry) {
407
+ const lines = content.replace(/\s*$/, "").split("\n");
408
+ let unreleasedIndex = lines.findIndex((line) => line.trim() === "## [Unreleased]");
409
+ if (unreleasedIndex === -1) {
410
+ const firstVersionIndex = lines.findIndex((line) => line.startsWith("## "));
411
+ const insertIndex = firstVersionIndex === -1 ? lines.length : firstVersionIndex;
412
+ lines.splice(insertIndex, 0, "## [Unreleased]", "");
413
+ unreleasedIndex = insertIndex;
414
+ }
415
+ const blockEnd = this.findNextHeadingIndex(lines, unreleasedIndex + 1, "## ");
416
+ const unreleasedLines = lines.slice(unreleasedIndex, blockEnd);
417
+ if (unreleasedLines.includes(entry.line)) {
418
+ return `${lines.join("\n")}\n`;
419
+ }
420
+ const sectionHeading = `### ${entry.section}`;
421
+ const sectionIndex = lines.findIndex((line, index) => index > unreleasedIndex && index < blockEnd && line.trim() === sectionHeading);
422
+ if (sectionIndex >= 0) {
423
+ const insertIndex = lines[sectionIndex + 1] === "" ? sectionIndex + 2 : sectionIndex + 1;
424
+ lines.splice(insertIndex, 0, entry.line);
425
+ return `${lines.join("\n")}\n`;
426
+ }
427
+ const insertIndex = this.findChangelogSectionInsertIndex(lines, unreleasedIndex, blockEnd, entry.section);
428
+ lines.splice(insertIndex, 0, `### ${entry.section}`, "", entry.line, "");
429
+ return `${lines.join("\n").replace(/\s*$/, "")}\n`;
430
+ }
431
+ findNextHeadingIndex(lines, startIndex, headingPrefix) {
432
+ const found = lines.findIndex((line, index) => index >= startIndex && line.startsWith(headingPrefix));
433
+ return found === -1 ? lines.length : found;
434
+ }
435
+ findChangelogSectionInsertIndex(lines, unreleasedIndex, blockEnd, section) {
436
+ const sectionOrder = ["Added", "Changed"];
437
+ const sectionRank = sectionOrder.indexOf(section);
438
+ for (let index = unreleasedIndex + 1; index < blockEnd; index += 1) {
439
+ const line = lines[index].trim();
440
+ if (!line.startsWith("### "))
441
+ continue;
442
+ const foundSection = line.slice(4);
443
+ const foundRank = sectionOrder.indexOf(foundSection);
444
+ if (foundRank > sectionRank) {
445
+ return index;
446
+ }
447
+ }
448
+ return blockEnd;
449
+ }
450
+ async readResourceVersion(repoDir, type, name) {
451
+ const yamlPath = path.join(repoDir, this.getTypeDir(type), name, "himan.yaml");
452
+ try {
453
+ const raw = await fs.readFile(yamlPath, "utf8");
454
+ const parsed = YAML.parse(raw);
455
+ return typeof parsed?.version === "string" ? parsed.version : undefined;
456
+ }
457
+ catch (error) {
458
+ if (!this.isNotFoundError(error)) {
459
+ throw error;
460
+ }
461
+ return undefined;
462
+ }
463
+ }
464
+ getSourceTitle() {
465
+ const repo = this.sourceConfig?.repo?.replace(/\/$/, "");
466
+ const repoName = repo?.split(/[/:]/).at(-1)?.replace(/\.git$/, "");
467
+ return repoName ? `${repoName} Himan Source` : "Himan Source";
468
+ }
469
+ getTypeLabel(type) {
470
+ if (type === "rule")
471
+ return "Rules";
472
+ if (type === "command")
473
+ return "Commands";
474
+ return "Skills";
475
+ }
149
476
  }
@@ -18,4 +18,7 @@ export class RegistrySourceAdapter {
18
18
  async create(_type, _name, _options) {
19
19
  throw new HimanError(errorCodes.NOT_IMPLEMENTED, "Registry source is reserved for phase 2.");
20
20
  }
21
+ async initDocs(_options) {
22
+ throw new HimanError(errorCodes.NOT_IMPLEMENTED, "Registry source is reserved for phase 2.");
23
+ }
21
24
  }
@@ -60,7 +60,7 @@ function appendCommandGroupsHelp(program) {
60
60
  program.addHelpText("after", `
61
61
  Command groups:
62
62
  source Data source management (git now, registry reserved)
63
- init, source init, source add, source use, source list
63
+ init, source init, source add, source use, source list, source init-docs
64
64
  resource Source resource discovery and metadata
65
65
  list, history, create, resource list, resource history, resource create
66
66
  project Resource usage lifecycle in current project
@@ -55,4 +55,26 @@ export function registerSourceCommands(command, services, options) {
55
55
  }
56
56
  });
57
57
  });
58
+ command
59
+ .command("init-docs")
60
+ .option("--force", "overwrite existing README.md and CHANGELOG.md")
61
+ .option("--dry-run", "show files without writing")
62
+ .option("--json", "output json format")
63
+ .description("Create source-level README.md and CHANGELOG.md")
64
+ .action(async (options) => {
65
+ await runAction(async () => {
66
+ const result = await services.initSourceDocs({
67
+ force: options.force,
68
+ dryRun: options.dryRun,
69
+ });
70
+ if (options.json) {
71
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
72
+ return;
73
+ }
74
+ process.stdout.write(`Source docs ${result.dryRun ? "dry-run" : "initialized"}: ${result.sourceDir}\n`);
75
+ for (const file of result.files) {
76
+ process.stdout.write(`- ${file.action} ${file.path}${file.reason ? ` (${file.reason})` : ""}\n`);
77
+ }
78
+ });
79
+ });
58
80
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -2,7 +2,7 @@ import { GitSourceAdapter } from "../adapters/source/git-source-adapter.js";
2
2
  import { RegistrySourceAdapter } from "../adapters/source/registry-source-adapter.js";
3
3
  import { StateStore } from "../state/state-store.js";
4
4
  import { ProjectConfigStore } from "../state/project-config-store.js";
5
- import { ProjectLockStore } from "../state/project-lock-store.js";
5
+ import { ProjectLockStore, } from "../state/project-lock-store.js";
6
6
  import { PathResolver } from "../utils/path-resolver.js";
7
7
  import { toRepoId } from "../utils/repo-id.js";
8
8
  import { HimanError, errorCodes } from "../utils/errors.js";
@@ -101,6 +101,10 @@ export class ServiceFactory {
101
101
  isDefault: name === config.sources?.default,
102
102
  }));
103
103
  }
104
+ async initSourceDocs(options) {
105
+ const source = await this.loadSourceFromConfig();
106
+ return source.initDocs(options);
107
+ }
104
108
  async setAgents(agents, scope, projectDir) {
105
109
  const normalized = normalizeAgents(agents);
106
110
  if (scope === "project") {
@@ -158,31 +162,8 @@ export class ServiceFactory {
158
162
  return source.history(type, name);
159
163
  }
160
164
  async install(type, name, version, projectDir, agents, mode = "link") {
161
- const source = await this.loadSourceFromConfig();
162
- const sourceInfo = await this.getLockSourceInfo();
163
- const history = await source.history(type, name);
164
- if (history.length === 0) {
165
- throw new HimanError(errorCodes.RESOURCE_NOT_FOUND, `Resource not found: ${type}/${name}`);
166
- }
167
- const resolvedVersion = this.resolveVersion(history, version);
168
- const storePath = this.getStorePath(type, name, resolvedVersion);
169
- if (!(await this.exists(storePath))) {
170
- await source.pull(type, name, resolvedVersion, storePath);
171
- }
172
- const resourceMeta = await this.readResourceMetaFromDir(storePath);
173
- const effectiveTargets = await this.resolveEffectiveAgents(projectDir, agents, resourceMeta?.agents);
174
- const linkPaths = getProjectResourcePaths(projectDir, type, name, effectiveTargets);
175
- for (const linkPath of linkPaths) {
176
- await this.materializeResource(storePath, linkPath, mode);
177
- }
178
- await this.lockStore.upsertResource(projectDir, sourceInfo, {
179
- type,
180
- name,
181
- version: resolvedVersion,
182
- agents: effectiveTargets,
183
- mode,
184
- });
185
- return { type, name, version: resolvedVersion, linkPath: linkPaths[0], mode };
165
+ const { source, sourceInfo } = await this.loadSourceWithInfoFromConfig();
166
+ return this.installWithSource(source, sourceInfo, type, name, version, projectDir, agents, mode);
186
167
  }
187
168
  async dev(type, name, projectDir) {
188
169
  const installInfo = await this.resolveInstalledResource(projectDir, type, name);
@@ -275,21 +256,69 @@ export class ServiceFactory {
275
256
  throw new HimanError(errorCodes.LOCK_NOT_FOUND, `Lock file has no resources: ${this.lockStore.getLockPath(projectDir)}`);
276
257
  }
277
258
  const results = [];
259
+ const lockSourceInfo = this.normalizeLockSourceInfo(lock.source);
260
+ const lockedSource = await this.loadSourceFromLock(lockSourceInfo);
278
261
  for (const item of lock.resources) {
279
- const result = await this.install(item.type, item.name, item.version, projectDir, agents ?? item.agents, mode ?? this.resolveInstallMode(item.mode));
262
+ const result = await this.installWithSource(lockedSource, lockSourceInfo, item.type, item.name, item.version, projectDir, agents ?? item.agents, mode ?? this.resolveInstallMode(item.mode));
280
263
  results.push(result);
281
264
  }
282
265
  return results;
283
266
  }
267
+ async installWithSource(source, sourceInfo, type, name, version, projectDir, agents, mode) {
268
+ const history = await source.history(type, name);
269
+ if (history.length === 0) {
270
+ throw new HimanError(errorCodes.RESOURCE_NOT_FOUND, `Resource not found: ${type}/${name}`);
271
+ }
272
+ const resolvedVersion = this.resolveVersion(history, version);
273
+ const storePath = this.getStorePath(type, name, resolvedVersion);
274
+ if (!(await this.exists(storePath))) {
275
+ await source.pull(type, name, resolvedVersion, storePath);
276
+ }
277
+ const resourceMeta = await this.readResourceMetaFromDir(storePath);
278
+ const effectiveTargets = await this.resolveEffectiveAgents(projectDir, agents, resourceMeta?.agents);
279
+ const linkPaths = getProjectResourcePaths(projectDir, type, name, effectiveTargets);
280
+ for (const linkPath of linkPaths) {
281
+ await this.materializeResource(storePath, linkPath, mode);
282
+ }
283
+ await this.lockStore.upsertResource(projectDir, sourceInfo, {
284
+ type,
285
+ name,
286
+ version: resolvedVersion,
287
+ agents: effectiveTargets,
288
+ mode,
289
+ });
290
+ return { type, name, version: resolvedVersion, linkPath: linkPaths[0], mode };
291
+ }
284
292
  async loadSourceFromConfig() {
293
+ return (await this.loadSourceWithInfoFromConfig()).source;
294
+ }
295
+ async loadSourceWithInfoFromConfig() {
296
+ const { name, source: stateSource } = await this.getCurrentSourceState();
297
+ const sourceInfo = this.toLockSourceInfo(stateSource, name);
298
+ const source = await this.loadSourceFromInfo(sourceInfo);
299
+ return {
300
+ source,
301
+ sourceInfo,
302
+ };
303
+ }
304
+ async loadSourceFromLock(sourceInfo) {
305
+ return this.loadSourceFromInfo(sourceInfo);
306
+ }
307
+ async loadSourceFromInfo(sourceInfo) {
308
+ const normalizedSourceInfo = this.normalizeLockSourceInfo(sourceInfo);
309
+ const sourceConfig = this.buildSourceConfig(normalizedSourceInfo.type, normalizedSourceInfo.repo, normalizedSourceInfo.repoId);
310
+ const source = this.createSource(normalizedSourceInfo.type);
311
+ await source.init(sourceConfig);
312
+ return source;
313
+ }
314
+ async getCurrentSourceState() {
285
315
  const config = await this.stateStore.loadConfig();
286
316
  if (!config?.source) {
287
317
  throw new HimanError(errorCodes.CONFIG_NOT_FOUND, "Source config not found. Please run `himan init <git_repo>` first.");
288
318
  }
289
- const sourceConfig = this.buildSourceConfig(config.source.type, config.source.repo, config.source.repoId);
290
- const source = this.createSource(config.source.type);
291
- await source.init(sourceConfig);
292
- return source;
319
+ const currentName = config.sources?.default ?? "default";
320
+ const currentSource = config.sources?.items[currentName] ?? config.source;
321
+ return { name: currentName, source: currentSource };
293
322
  }
294
323
  createSource(type) {
295
324
  return type === "registry"
@@ -297,14 +326,24 @@ export class ServiceFactory {
297
326
  : new GitSourceAdapter();
298
327
  }
299
328
  async getLockSourceInfo() {
300
- const config = await this.stateStore.loadConfig();
301
- if (!config?.source) {
302
- throw new HimanError(errorCodes.CONFIG_NOT_FOUND, "Source config not found. Please run `himan init <git_repo>` first.");
329
+ const { name, source } = await this.getCurrentSourceState();
330
+ return this.toLockSourceInfo(source, name);
331
+ }
332
+ toLockSourceInfo(source, name) {
333
+ return this.normalizeLockSourceInfo({
334
+ name,
335
+ type: source.type,
336
+ repo: source.repo,
337
+ repoId: source.repoId,
338
+ });
339
+ }
340
+ normalizeLockSourceInfo(sourceInfo) {
341
+ if (sourceInfo.type !== "git" || !sourceInfo.repo) {
342
+ return sourceInfo;
303
343
  }
304
344
  return {
305
- type: config.source.type,
306
- repo: config.source.repo,
307
- repoId: config.source.repoId,
345
+ ...sourceInfo,
346
+ repoId: sourceInfo.repoId ?? toRepoId(sourceInfo.repo),
308
347
  };
309
348
  }
310
349
  async getLockedResource(projectDir, type, name) {
@@ -12,12 +12,13 @@ export class IndexCacheStore {
12
12
  return null;
13
13
  return data.entries.find((item) => item.repoId === repoId && item.type === type) ?? null;
14
14
  }
15
- async upsert(repoId, type, baseDirMtimeMs, resources) {
15
+ async upsert(repoId, type, metadataHash, resources) {
16
16
  const now = new Date().toISOString();
17
17
  const file = (await this.load()) ?? { version: 1, entries: [] };
18
18
  const found = file.entries.find((item) => item.repoId === repoId && item.type === type);
19
19
  if (found) {
20
- found.baseDirMtimeMs = baseDirMtimeMs;
20
+ found.metadataHash = metadataHash;
21
+ delete found.baseDirMtimeMs;
21
22
  found.resources = resources;
22
23
  found.updatedAt = now;
23
24
  }
@@ -25,7 +26,7 @@ export class IndexCacheStore {
25
26
  file.entries.push({
26
27
  repoId,
27
28
  type,
28
- baseDirMtimeMs,
29
+ metadataHash,
29
30
  resources,
30
31
  updatedAt: now,
31
32
  });
@@ -21,5 +21,7 @@ export const errorCodes = {
21
21
  RESOURCE_EXISTS: "E_RESOURCE_EXISTS",
22
22
  TEMPLATE_NOT_FOUND: "E_TEMPLATE_NOT_FOUND",
23
23
  INVALID_RESOURCE_NAME: "E_INVALID_RESOURCE_NAME",
24
+ INVALID_RESOURCE_METADATA: "E_INVALID_RESOURCE_METADATA",
25
+ PUBLISH_NO_CHANGES: "E_PUBLISH_NO_CHANGES",
24
26
  UNSUPPORTED_RESOURCE_TYPE: "E_UNSUPPORTED_RESOURCE_TYPE",
25
27
  };