docopslab-dev 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +645 -318
  3. data/docopslab-dev.gemspec +2 -3
  4. data/docs/agent/index.md +4 -8
  5. data/docs/agent/misc/bash-styles.md +470 -0
  6. data/docs/agent/missions/conduct-release.md +161 -87
  7. data/docs/agent/missions/setup-new-project.md +228 -134
  8. data/docs/agent/roles/devops-release-engineer.md +60 -17
  9. data/docs/agent/roles/docops-engineer.md +84 -20
  10. data/docs/agent/roles/planner-architect.md +22 -0
  11. data/docs/agent/roles/product-engineer.md +63 -15
  12. data/docs/agent/roles/product-manager.md +57 -24
  13. data/docs/agent/roles/project-manager.md +48 -12
  14. data/docs/agent/roles/qa-testing-engineer.md +48 -14
  15. data/docs/agent/roles/tech-docs-manager.md +63 -17
  16. data/docs/agent/roles/tech-writer.md +68 -14
  17. data/docs/agent/skills/asciidoc.md +65 -238
  18. data/docs/agent/skills/bash-cli-dev.md +135 -0
  19. data/docs/agent/skills/code-commenting.md +143 -106
  20. data/docs/agent/skills/fix-broken-links.md +145 -100
  21. data/docs/agent/skills/fix-jekyll-asciidoc-build-errors.md +1 -10
  22. data/docs/agent/skills/fix-spelling-issues.md +0 -3
  23. data/docs/agent/skills/git.md +69 -34
  24. data/docs/agent/skills/github-issues.md +110 -71
  25. data/docs/agent/skills/rake-cli-dev.md +1 -1
  26. data/docs/agent/skills/readme-driven-dev.md +1 -0
  27. data/docs/agent/skills/release-history.md +1 -7
  28. data/docs/agent/skills/ruby.md +18 -7
  29. data/docs/agent/skills/schemagraphy-sgyml.md +3 -0
  30. data/docs/agent/skills/tests-running.md +22 -14
  31. data/docs/agent/skills/tests-writing.md +51 -28
  32. data/docs/agent/skills/write-the-docs.md +71 -9
  33. data/docs/agent/topics/common-project-paths.md +122 -70
  34. data/docs/agent/topics/dev-tooling-usage.md +70 -77
  35. data/docs/agent/topics/devops-ci-cd.md +3 -1
  36. data/docs/agent/topics/product-docs-deployment.md +18 -12
  37. data/docs/library-readme.adoc +39 -0
  38. data/lib/docopslab/dev/cast_ops.rb +199 -0
  39. data/lib/docopslab/dev/config_manager.rb +6 -6
  40. data/lib/docopslab/dev/data_utils.rb +42 -0
  41. data/lib/docopslab/dev/file_utils.rb +18 -7
  42. data/lib/docopslab/dev/git_branch.rb +201 -0
  43. data/lib/docopslab/dev/git_hooks.rb +17 -11
  44. data/lib/docopslab/dev/initializer.rb +13 -4
  45. data/lib/docopslab/dev/library/cache.rb +167 -0
  46. data/lib/docopslab/dev/library/fetch.rb +209 -0
  47. data/lib/docopslab/dev/library.rb +328 -0
  48. data/lib/docopslab/dev/linters.rb +63 -12
  49. data/lib/docopslab/dev/manifest.rb +28 -0
  50. data/lib/docopslab/dev/paths.rb +0 -17
  51. data/lib/docopslab/dev/script_manager.rb +12 -6
  52. data/lib/docopslab/dev/skim.rb +109 -0
  53. data/lib/docopslab/dev/spell_check.rb +2 -2
  54. data/lib/docopslab/dev/sync_ops.rb +94 -33
  55. data/lib/docopslab/dev/tasks.rb +58 -18
  56. data/lib/docopslab/dev/version.rb +1 -1
  57. data/lib/docopslab/dev.rb +75 -35
  58. data/specs/data/default-manifest.yml +15 -5
  59. data/specs/data/library-index.yml +22 -0
  60. data/specs/data/manifest-schema.yaml +142 -4
  61. data/specs/data/tasks-def.yml +122 -10
  62. metadata +28 -39
  63. data/assets/config-packs/actionlint/base.yml +0 -13
  64. data/assets/config-packs/actionlint/project.yml +0 -13
  65. data/assets/config-packs/htmlproofer/base.yml +0 -27
  66. data/assets/config-packs/htmlproofer/project.yml +0 -25
  67. data/assets/config-packs/rubocop/base.yml +0 -130
  68. data/assets/config-packs/rubocop/project.yml +0 -8
  69. data/assets/config-packs/shellcheck/base.shellcheckrc +0 -14
  70. data/assets/config-packs/subtxt/ai-asciidoc-antipatterns.sub.txt +0 -11
  71. data/assets/config-packs/vale/asciidoc/ExplicitSectionIDs.yml +0 -8
  72. data/assets/config-packs/vale/asciidoc/ExtraLineBeforeLevel1.yml +0 -7
  73. data/assets/config-packs/vale/asciidoc/OneSentencePerLine.yml +0 -8
  74. data/assets/config-packs/vale/asciidoc/PreferSourceBlocks.yml +0 -8
  75. data/assets/config-packs/vale/asciidoc/ProperAdmonitions.yml +0 -8
  76. data/assets/config-packs/vale/asciidoc/ProperDLs.yml +0 -7
  77. data/assets/config-packs/vale/asciidoc/UncleanListStart.yml +0 -8
  78. data/assets/config-packs/vale/authoring/ButParagraph.yml +0 -8
  79. data/assets/config-packs/vale/authoring/ExNotEg.yml +0 -8
  80. data/assets/config-packs/vale/authoring/LiteralTerms.yml +0 -20
  81. data/assets/config-packs/vale/authoring/Spelling.yml +0 -679
  82. data/assets/config-packs/vale/base.ini +0 -38
  83. data/assets/config-packs/vale/config/scripts/ExplicitSectionIDs.tengo +0 -56
  84. data/assets/config-packs/vale/config/scripts/ExtraLineBeforeLevel1.tengo +0 -121
  85. data/assets/config-packs/vale/config/scripts/OneSentencePerLine.tengo +0 -53
  86. data/assets/config-packs/vale/project.ini +0 -5
  87. data/assets/hooks/pre-commit +0 -63
  88. data/assets/hooks/pre-push +0 -72
  89. data/assets/scripts/adoc_section_ids.rb +0 -50
  90. data/assets/scripts/build-common.sh +0 -193
  91. data/assets/scripts/build-docker.sh +0 -64
  92. data/assets/scripts/build.sh +0 -56
  93. data/assets/scripts/parse_jekyll_asciidoc_logs.rb +0 -467
  94. data/assets/templates/Gemfile +0 -7
  95. data/assets/templates/Rakefile +0 -3
  96. data/assets/templates/gitignore +0 -69
  97. data/assets/templates/jekyll-asciidoc-fix.prompt.yml +0 -17
  98. data/assets/templates/spellcheck.prompt.yml +0 -16
  99. data/docs/agent/AGENTS.md +0 -229
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'digest'
6
+ require_relative '../../dev'
7
+
8
+ module DocOpsLab
9
+ module Dev
10
+ module Library
11
+ # Manages the host-wide library cache at ~/.cache/docopslab/dev/library/.
12
+ #
13
+ # Cache layout:
14
+ # current/ Active library used for sync and resolve operations.
15
+ # previous/ Previous snapshot retained for fast rollback.
16
+ #
17
+ # The XDG_CACHE_HOME env variable is respected; defaults to ~/.cache.
18
+ module Cache
19
+ class << self
20
+ # Optional path override; set by Library.sync!/fetch! when manifest
21
+ # specifies `library.sync.cache_root`. Cleared after the operation.
22
+ attr_writer :root_override
23
+
24
+ # Absolute path to the cache root (~/.cache/docopslab/dev/library/).
25
+ # Respects +root_override+ if set, then $XDG_CACHE_HOME, then ~/.cache.
26
+ def root
27
+ return File.expand_path(@root_override) if @root_override
28
+
29
+ base = ENV.fetch('XDG_CACHE_HOME', File.join(Dir.home, '.cache'))
30
+ File.join(base, Dev.xdg_cache_subpath)
31
+ end
32
+
33
+ # Temporarily override the cache root for the duration of a block.
34
+ # Restores the previous value even if the block raises.
35
+ def with_root_override path
36
+ previous = @root_override
37
+ @root_override = path
38
+ yield
39
+ ensure
40
+ @root_override = previous
41
+ end
42
+
43
+ # Absolute path to the current library snapshot.
44
+ def current_path
45
+ File.join(root, 'current')
46
+ end
47
+
48
+ # Absolute path to the previous library snapshot (rollback target).
49
+ def previous_path
50
+ File.join(root, 'previous')
51
+ end
52
+
53
+ # Absolute path to the catalog inside the current snapshot.
54
+ def catalog_path
55
+ File.join(current_path, 'catalog.json')
56
+ end
57
+
58
+ # Load and return the parsed catalog from the current snapshot.
59
+ # Returns nil if no cache is present or the catalog is unreadable.
60
+ def catalog
61
+ return nil unless File.exist?(catalog_path)
62
+
63
+ load_catalog_json(catalog_path)
64
+ end
65
+
66
+ # True if a current snapshot with a readable catalog is present.
67
+ def available?
68
+ File.exist?(catalog_path)
69
+ end
70
+
71
+ # The remote HEAD SHA stored from the last successful fetch, or nil.
72
+ def stored_head
73
+ return nil unless File.exist?(head_path)
74
+
75
+ s = File.read(head_path).strip
76
+ s.empty? ? nil : s
77
+ rescue StandardError
78
+ nil
79
+ end
80
+
81
+ # Persist the remote HEAD SHA alongside the cache.
82
+ def write_head! sha
83
+ FileUtils.mkdir_p(root)
84
+ File.write(head_path, sha.to_s.strip)
85
+ end
86
+
87
+ # True if the cache exists and was generated within +max_age_hours+.
88
+ def fresh? max_age_hours=24
89
+ return false unless available?
90
+
91
+ ts = catalog&.dig('generated_at')
92
+ return false unless ts
93
+
94
+ (Time.now.utc - Time.parse(ts)) < max_age_hours * 3600
95
+ rescue ArgumentError
96
+ false
97
+ end
98
+
99
+ # Rotate current/ to previous/, removing any prior previous/ snapshot.
100
+ # Returns true if a rotation was performed, false if current/ was absent.
101
+ def rotate!
102
+ return false unless Dir.exist?(current_path)
103
+
104
+ FileUtils.rm_rf(previous_path)
105
+ FileUtils.mv(current_path, previous_path)
106
+ true
107
+ end
108
+
109
+ # Install a directory as the new current/ snapshot.
110
+ # Rotates any existing current/ to previous/ first.
111
+ # source_dir must be an existing directory.
112
+ def write! source_dir
113
+ raise ArgumentError, "Source directory not found: #{source_dir}" unless Dir.exist?(source_dir)
114
+
115
+ rotate! if Dir.exist?(current_path)
116
+ FileUtils.mkdir_p(File.dirname(current_path))
117
+ FileUtils.cp_r(source_dir, current_path)
118
+ true
119
+ end
120
+
121
+ # Swap previous/ back to current/.
122
+ # Returns false if no previous/ snapshot exists.
123
+ def rollback!
124
+ return false unless Dir.exist?(previous_path)
125
+
126
+ FileUtils.rm_rf(current_path)
127
+ FileUtils.mv(previous_path, current_path)
128
+ true
129
+ end
130
+
131
+ # Return a status hash describing the current snapshot.
132
+ def status
133
+ if available?
134
+ meta = catalog
135
+ {
136
+ available: true,
137
+ version: meta&.dig('library_version'),
138
+ ref: meta&.dig('library_ref'),
139
+ generated_at: meta&.dig('generated_at'),
140
+ cache_path: current_path,
141
+ has_previous: Dir.exist?(previous_path)
142
+ }
143
+ else
144
+ { available: false, cache_path: current_path }
145
+ end
146
+ end
147
+
148
+ CATALOG_JSON_KEYS = %w[library_version library_ref generated_at files].freeze
149
+
150
+ private
151
+
152
+ def head_path
153
+ File.join(root, 'remote_head')
154
+ end
155
+
156
+ # Parse catalog.json, returning the hash or nil on any error.
157
+ def load_catalog_json path
158
+ data = JSON.parse(File.read(path))
159
+ CATALOG_JSON_KEYS.all? { |k| data.key?(k) } ? data : nil
160
+ rescue JSON::ParserError
161
+ nil
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'shellwords'
5
+ require 'tmpdir'
6
+ require 'zlib'
7
+
8
+ module DocOpsLab
9
+ module Dev
10
+ module Library
11
+ # Fetches the remote asset library and installs it to the host cache.
12
+ #
13
+ # Fetch strategy (in order of preference):
14
+ # 1. `gh` CLI downloads the branch via the GitHub API tarball endpoint.
15
+ # 2. `git clone --depth=1` with sparse checkout; pulls only the library
16
+ # sub-tree from the remote branch.
17
+ #
18
+ # Auth notes:
19
+ # DocOps/lab is a public repository; no credentials are required.
20
+ # A GITHUB_TOKEN env variable can be added in a future iteration if
21
+ # private repo support is needed.
22
+ module Fetch
23
+ class << self
24
+ # Fetch the remote library and install it to the host cache.
25
+ #
26
+ # config is a hash (or nil) mirroring the `library.source` manifest
27
+ # block:
28
+ # { 'repo' => '...', 'branch' => '...', 'path' => '...' }
29
+ #
30
+ # Returns true on success, false on failure (logs a warning).
31
+ def call config={}
32
+ source = resolve_source(config)
33
+
34
+ Dir.mktmpdir('docopslab-library-') do |tmpdir|
35
+ dest = File.join(tmpdir, 'library')
36
+ FileUtils.mkdir_p(dest)
37
+
38
+ ok = fetch_git_content(source[:repo], source[:branch], source[:path], dest)
39
+ unless ok
40
+ warn '⚠️ Library fetch failed; cache not updated.'
41
+ return false
42
+ end
43
+
44
+ sha = remote_head_for_source(source)
45
+ Cache.write!(dest)
46
+ Cache.write_head!(sha) if sha
47
+ puts "✅ Library fetched and cached at #{Cache.current_path}"
48
+ true
49
+ end
50
+ rescue StandardError => e
51
+ warn "⚠️ Library fetch error: #{e.message}"
52
+ false
53
+ end
54
+
55
+ # True if the `gh` CLI is present and executable.
56
+ def gh_available?
57
+ system('gh --version > /dev/null 2>&1')
58
+ end
59
+
60
+ # True if the `git` CLI is present and executable.
61
+ def git_available?
62
+ system('git --version > /dev/null 2>&1')
63
+ end
64
+
65
+ # Look up the current HEAD SHA for the configured remote branch.
66
+ # Runs `git ls-remote` (requires git and network access.)
67
+ # Returns the SHA string, or nil on failure.
68
+ def remote_head config={}
69
+ remote_head_for_source(resolve_source(config))
70
+ end
71
+
72
+ private
73
+
74
+ def remote_head_for_source source
75
+ url = "https://github.com/#{source[:repo]}.git"
76
+ ref = "refs/heads/#{source[:branch]}"
77
+ out = `git ls-remote #{Shellwords.escape(url)} #{Shellwords.escape(ref)} 2>/dev/null`
78
+ sha = out.split("\t").first&.strip
79
+ sha&.empty? ? nil : sha
80
+ rescue StandardError
81
+ nil
82
+ end
83
+
84
+ def resolve_source config
85
+ src = config.is_a?(Hash) ? (config['source'] || config[:source] || {}) : {}
86
+ raw = src['path'] || src[:path]
87
+ {
88
+ repo: src['repo'] || src[:repo] || Dev.default_library_repo,
89
+ branch: src['branch'] || src[:branch] || Dev.default_library_branch,
90
+ path: raw&.to_s&.delete_suffix('/')
91
+ }
92
+ end
93
+
94
+ # Route to the best available CLI tool.
95
+ # Prefer git clone (simpler, transparent, no extraction layer).
96
+ # Fall back to gh tarball download if git is unavailable.
97
+ def fetch_git_content repo, branch, path, dest
98
+ if git_available?
99
+ fetch_via_git_clone(repo, branch, path, dest)
100
+ elsif gh_available?
101
+ fetch_via_gh(repo, branch, path, dest)
102
+ else
103
+ warn '⚠️ Neither `git` nor `gh` CLI is available. Cannot fetch library.'
104
+ false
105
+ end
106
+ end
107
+
108
+ # Use `gh api` to download the branch tarball, then extract the
109
+ # library sub-path.
110
+ def fetch_via_gh repo, branch, path, dest
111
+ owner, repo_name = repo.split('/', 2)
112
+ Dir.mktmpdir('docopslab-gh-') do |tmpdir|
113
+ archive = File.join(tmpdir, 'library.tar.gz')
114
+ cmd = "gh api repos/#{Shellwords.escape(owner)}/#{Shellwords.escape(repo_name)}" \
115
+ "/tarball/#{Shellwords.escape(branch)} " \
116
+ "> #{Shellwords.escape(archive)}"
117
+ unless system(cmd)
118
+ warn '⚠️ `gh api` call failed.'
119
+ return false
120
+ end
121
+ extract_subpath_from_tarball(archive, path, dest)
122
+ end
123
+ end
124
+
125
+ # Use `git clone --depth=1` to pull the library branch.
126
+ # If +path+ is given, only that subdirectory is checked out (sparse);
127
+ # otherwise the entire branch root is copied.
128
+ def fetch_via_git_clone repo, branch, path, dest
129
+ Dir.mktmpdir('docopslab-git-') do |tmpdir|
130
+ clone_dir = File.join(tmpdir, 'clone')
131
+ url = "https://github.com/#{repo}.git"
132
+ clone_flags = path ? '--filter=blob:none --sparse' : ''
133
+ clone_cmd = [
134
+ 'git clone',
135
+ '--depth=1',
136
+ "--branch #{Shellwords.escape(branch)}",
137
+ clone_flags,
138
+ Shellwords.escape(url),
139
+ Shellwords.escape(clone_dir)
140
+ ].reject(&:empty?).join(' ')
141
+
142
+ unless system(clone_cmd)
143
+ warn '⚠️ `git clone` failed.'
144
+ return false
145
+ end
146
+
147
+ if path
148
+ sparse_cmd = "git -C #{Shellwords.escape(clone_dir)} sparse-checkout set #{Shellwords.escape(path)}"
149
+ system(sparse_cmd)
150
+ end
151
+
152
+ source_path = path ? File.join(clone_dir, path) : clone_dir
153
+ unless Dir.exist?(source_path)
154
+ warn "⚠️ Library path '#{path}' not found in remote branch."
155
+ return false
156
+ end
157
+
158
+ FileUtils.cp_r("#{source_path}/.", dest)
159
+ true
160
+ end
161
+ end
162
+
163
+ # Extract all entries under `path/` from a GitHub-format tarball.
164
+ # GitHub tarballs wrap everything in a top-level `owner-repo-SHA/`
165
+ # prefix directory; this method strips that prefix automatically.
166
+ def extract_subpath_from_tarball archive, path, dest
167
+ require 'rubygems/package'
168
+
169
+ Dir.mktmpdir('docopslab-tar-') do |extract_dir|
170
+ Zlib::GzipReader.open(archive) do |gz|
171
+ Gem::Package::TarReader.new(gz) do |tar|
172
+ tar.each do |entry|
173
+ parts = entry.full_name.split('/', 2)
174
+ next if parts.length < 2
175
+
176
+ relative = parts[1]
177
+ next if relative.empty?
178
+
179
+ if path
180
+ next unless relative.start_with?("#{path}/") || relative == path
181
+
182
+ local_relative = relative.delete_prefix("#{path}/")
183
+ else
184
+ local_relative = relative
185
+ end
186
+ target = File.join(extract_dir, local_relative)
187
+
188
+ if entry.directory?
189
+ FileUtils.mkdir_p(target)
190
+ elsif entry.file?
191
+ FileUtils.mkdir_p(File.dirname(target))
192
+ File.binwrite(target, entry.read)
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ FileUtils.cp_r("#{extract_dir}/.", dest)
199
+ end
200
+ true
201
+ rescue StandardError => e
202
+ warn "⚠️ Tarball extraction failed: #{e.message}"
203
+ false
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'shellwords'
6
+ require 'tmpdir'
7
+ require_relative 'manifest'
8
+ require_relative 'library/cache'
9
+ require_relative 'library/fetch'
10
+
11
+ module DocOpsLab
12
+ module Dev
13
+ # Remote library fetch, cache, and resolution.
14
+ # Manages a host-wide asset cache at ~/.cache/docopslab/dev/library/.
15
+ # Callers should use this module directly: Library.fetch!, Library.resolve(path), etc.
16
+ module Library
17
+ class << self
18
+ def fetch! config=nil
19
+ config ||= library_config_from_manifest
20
+ with_cache_root(config) { Fetch.call(config) }
21
+ end
22
+
23
+ # Fetch the library if the cache is absent or stale, then sync all
24
+ # manifest-driven content (docs, config files, templates, scripts) to
25
+ # local paths. This is the main entry point for `labdev:sync:library`.
26
+ def sync! force: false
27
+ config = library_config_from_manifest
28
+ with_cache_root(config) do
29
+ if local_path_active?(config)
30
+ puts "📚 Using local library at #{File.expand_path(config['local_path'])}"
31
+ elsif !force && Cache.available? && sha_current?(config)
32
+ puts "\u2705 Library cache is up to date (#{Cache.stored_head&.slice(0, 8)})"
33
+ else
34
+ puts Cache.available? ? '🔄 Library has updates; refreshing...' : '📥 Library cache not found; fetching...'
35
+ ok = Fetch.call(config)
36
+ unless ok
37
+ warn '⚠️ Library fetch failed. Using existing cache if available.'
38
+ raise 'Library unavailable.' unless available?
39
+ end
40
+ end
41
+
42
+ context = Dev
43
+ SyncOps.sync_config_files(context)
44
+ SyncOps.sync_docs(context, force: force)
45
+ SyncOps.sync_templates(context, force: force)
46
+ SyncOps.sync_scripts(context)
47
+ end
48
+ end
49
+
50
+ # Copy a local library directory into the host cache and sync content to
51
+ # manifest-configured paths. Intended for development workflows where
52
+ # assets live in the +lab+ monorepo (.library/ or library/current/) and
53
+ # have not yet been published to the remote branch.
54
+ #
55
+ # Resolution order for +source_path+:
56
+ # 1. Explicit argument (task arg or direct call)
57
+ # 2. manifest +library.local_path+ (resolved relative to cwd)
58
+ # 3. .library/ in the current working directory
59
+ # 4. ../lab/.library/ relative to cwd (downstream-project fallback)
60
+ #
61
+ # A minimal catalog.json is generated into a staging copy if the source
62
+ # directory does not already contain one.
63
+ def stage! source_path: nil
64
+ resolved = resolve_stage_source(source_path)
65
+ unless resolved
66
+ warn '⚠️ No local library path found. ' \
67
+ "Pass a path, or set library.local_path in #{Dev::MANIFEST_PATH}."
68
+ return false
69
+ end
70
+
71
+ puts "📦 Staging local library from #{resolved}..."
72
+
73
+ Dir.mktmpdir('docopslab-stage-') do |tmpdir|
74
+ dest = File.join(tmpdir, 'stage')
75
+ FileUtils.cp_r(resolved, dest)
76
+ ensure_catalog!(dest)
77
+ Cache.write!(dest)
78
+ end
79
+
80
+ puts "✅ Local library staged to #{Cache.current_path}"
81
+
82
+ context = Dev
83
+ SyncOps.sync_config_files(context)
84
+ SyncOps.sync_docs(context, force: true)
85
+ SyncOps.sync_templates(context, force: true)
86
+ SyncOps.sync_scripts(context)
87
+ true
88
+ rescue StandardError => e
89
+ warn "⚠️ Stage failed: #{e.message}"
90
+ false
91
+ end
92
+
93
+ def cached_path
94
+ Cache.current_path
95
+ end
96
+
97
+ # Returns the effective library root directory (nil if unavailable).
98
+ # Does not auto-fetch; call ensure_available! first if needed.
99
+ # Resolution order mirrors resolve():
100
+ # 1. XDG host cache 2. local_path from manifest
101
+ def root
102
+ return Cache.current_path if Cache.available?
103
+
104
+ lp = Dev.load_manifest&.dig('library', 'local_path')
105
+ return File.expand_path(lp) if lp && File.exist?(File.join(lp, 'catalog.json'))
106
+
107
+ nil
108
+ end
109
+
110
+ # Returns the absolute path to a cached file, or nil if absent.
111
+ # Resolution order:
112
+ # 1. XDG host cache (~/.cache/docopslab/dev/library/current/)
113
+ # 2. local_path from manifest (dev/monorepo fallback, e.g. .library/)
114
+ def resolve relative_path
115
+ if Cache.available?
116
+ full_path = File.join(Cache.current_path, relative_path)
117
+ return full_path if File.exist?(full_path)
118
+ end
119
+
120
+ # local_path fallback for monorepo dev and offline use
121
+ lp = Dev.load_manifest&.dig('library', 'local_path')
122
+ if lp
123
+ local_full = File.expand_path(File.join(lp, relative_path))
124
+ return local_full if File.exist?(local_full)
125
+ end
126
+
127
+ nil
128
+ end
129
+
130
+ # True if a library is available via cache or local_path fallback.
131
+ def available?
132
+ return true if Cache.available?
133
+
134
+ lp = Dev.load_manifest&.dig('library', 'local_path')
135
+ !!(lp && File.exist?(File.join(lp, 'catalog.json')))
136
+ end
137
+
138
+ # Ensure the library is available, auto-fetching if necessary.
139
+ # Returns true if available after the call; raises on failure.
140
+ def ensure_available!
141
+ return true if available?
142
+
143
+ puts '📥 Library cache not found; fetching now...'
144
+ ok = fetch!
145
+ return true if ok && available?
146
+
147
+ lp = Dev.load_manifest&.dig('library', 'local_path')
148
+ if lp && Dir.exist?(lp)
149
+ warn "⚠️ Remote fetch failed; using local_path fallback: #{lp}"
150
+ return true
151
+ end
152
+
153
+ raise 'Library unavailable. Run `bundle exec rake labdev:sync:library` to fetch it.'
154
+ end
155
+
156
+ def status
157
+ Cache.status
158
+ end
159
+
160
+ def rollback!
161
+ if Cache.rollback!
162
+ puts "✅ Library rolled back to previous snapshot at #{Cache.current_path}"
163
+ true
164
+ else
165
+ warn '⚠️ No previous library snapshot available for rollback.'
166
+ false
167
+ end
168
+ end
169
+
170
+ def print_status
171
+ s = status
172
+ if s[:available]
173
+ puts "📚 Library cache: #{s[:cache_path]}"
174
+ puts " Version : #{s[:version] || '(unknown)'}"
175
+ puts " Ref : #{s[:ref] || '(unknown)'}"
176
+ puts " Generated : #{s[:generated_at] || '(unknown)'}"
177
+ puts " Previous : #{s[:has_previous] ? 'yes' : 'none'}"
178
+ else
179
+ puts "⚠️ No library cache found at #{s[:cache_path]}"
180
+ lp = Dev.load_manifest&.dig('library', 'local_path')
181
+ if lp && File.exist?(File.join(lp, 'catalog.json'))
182
+ puts " Local path : #{File.expand_path(lp)} (active fallback)"
183
+ else
184
+ puts ' Run `bundle exec rake labdev:sync:library` to fetch.'
185
+ end
186
+ end
187
+ end
188
+
189
+ # Compare manifest catalog entries against the cached library files
190
+ # Falls back to an on-repo local path if provided in the manifest
191
+ def print_catalog_comparison manifest = nil
192
+ manifest ||= Dev.load_manifest
193
+ lib_cfg = manifest && manifest['library']
194
+
195
+ if lib_cfg.nil? || lib_cfg.empty?
196
+ puts "ℹ️ No `library` block found in #{Dev.manifest_path} (or it's empty)."
197
+ return
198
+ end
199
+
200
+ catalog = lib_cfg.dig('catalog', 'overrides') || lib_cfg['catalog'] || lib_cfg['catalog_overrides']
201
+
202
+ unless catalog && !catalog.empty?
203
+ puts 'ℹ️ No catalog overrides found in manifest.library.catalog; nothing to compare.'
204
+ return
205
+ end
206
+
207
+ puts '🔎 Comparing manifest catalog entries to cached library files...'
208
+
209
+ entries = []
210
+ case catalog
211
+ when Array
212
+ entries = catalog
213
+ when Hash
214
+ catalog.each do |k, v|
215
+ entries << if v.is_a?(String)
216
+ v
217
+ elsif v.is_a?(Hash) && v['path']
218
+ v['path']
219
+ else
220
+ k
221
+ end
222
+ end
223
+ else
224
+ puts "⚠️ Unrecognized catalog format: #{catalog.class}. Skipping detailed compare."
225
+ return
226
+ end
227
+
228
+ missing = []
229
+ present = []
230
+
231
+ entries.each do |rel_path|
232
+ rel = rel_path.to_s.sub(%r{^/}, '')
233
+ resolved = resolve(rel)
234
+
235
+ # Fallback to on-repo local path if provided
236
+ if resolved.nil? && lib_cfg['local_path']
237
+ repo_local = File.join(Dir.pwd, lib_cfg['local_path'].to_s, rel)
238
+ resolved = File.exist?(repo_local) ? repo_local : nil
239
+ end
240
+
241
+ if resolved
242
+ present << { path: rel, full: resolved }
243
+ else
244
+ missing << rel
245
+ end
246
+ end
247
+
248
+ if present.any?
249
+ puts "✅ Found #{present.size} catalog entries in the cache or local path:"
250
+ present.each do |p|
251
+ puts " - #{p[:path]} -> #{p[:full]}"
252
+ end
253
+ end
254
+
255
+ if missing.any?
256
+ puts "❌ Missing #{missing.size} catalog entries in the cache/local path:"
257
+ missing.each do |m|
258
+ puts " - #{m}"
259
+ end
260
+ else
261
+ puts '✅ All catalog entries present in cache/local path.'
262
+ end
263
+ end
264
+
265
+ private
266
+
267
+ def sha_current? config
268
+ remote = Fetch.remote_head(config)
269
+ return Cache.fresh? unless remote # network unavailable; fall back to TTL
270
+
271
+ Cache.stored_head == remote
272
+ end
273
+
274
+ # True when local_path is configured and its catalog is present on disk.
275
+ # When active, sync! uses the local directory directly and skips the
276
+ # remote SHA check; the caller (library maintainer) manages it locally.
277
+ def local_path_active? config
278
+ lp = config['local_path']
279
+ lp && File.exist?(File.join(File.expand_path(lp), 'catalog.json'))
280
+ end
281
+
282
+ def with_cache_root(config, &)
283
+ cr = config.dig('sync', 'cache_root')
284
+ # Auto-derive from local_path when no explicit cache_root is set.
285
+ # local_path points to the 'current' snapshot dir, so its parent is
286
+ # the cache root (mirrors Cache::XDG_CACHE_SUBPATH layout).
287
+ cr = File.join(File.expand_path(config['local_path']), '..') if cr.nil? && local_path_active?(config)
288
+ Cache.with_root_override(cr ? File.expand_path(cr) : nil, &)
289
+ end
290
+
291
+ def library_config_from_manifest
292
+ Dev.load_manifest&.dig('library') || {}
293
+ end
294
+
295
+ # Resolve the source directory for stage! using the priority chain:
296
+ # explicit arg → manifest local_path → .library/ in cwd → ../lab/.library/
297
+ def resolve_stage_source explicit_path
298
+ candidates = []
299
+ candidates << File.expand_path(explicit_path) if explicit_path
300
+
301
+ lp = library_config_from_manifest['local_path']
302
+ candidates << File.expand_path(lp) if lp
303
+
304
+ candidates << File.join(Dir.pwd, '.library')
305
+ candidates << File.expand_path(File.join(Dir.pwd, '..', 'lab', '.library'))
306
+
307
+ candidates.find { |p| Dir.exist?(p) }
308
+ end
309
+
310
+ # Write a minimal catalog.json into +dir+ if one is not already present.
311
+ def ensure_catalog! dir
312
+ catalog_file = File.join(dir, 'catalog.json')
313
+ return if File.exist?(catalog_file)
314
+
315
+ files = Dir.glob("#{dir}/**/*").reject { |f| File.directory?(f) }
316
+ .map { |f| f.delete_prefix("#{dir}/") }
317
+ catalog = {
318
+ 'library_version' => 'local',
319
+ 'library_ref' => 'local-stage',
320
+ 'generated_at' => Time.now.utc.iso8601,
321
+ 'files' => files
322
+ }
323
+ File.write(catalog_file, JSON.generate(catalog))
324
+ end
325
+ end
326
+ end
327
+ end
328
+ end