Module: DocOpsLab::Dev::Library

Defined in:
lib/docopslab/dev/library.rb,
lib/docopslab/dev/library/cache.rb,
lib/docopslab/dev/library/fetch.rb

Overview

Remote library fetch, cache, and resolution. Manages a host-wide asset cache at ~/.cache/docopslab/dev/library/ (native) or ./.docopslab-cache/ (Docker without cache mount).

Docker Cache Strategy:

  • If running in Docker with host cache mounted (-v ~/.cache/docopslab:…): uses host cache path (DockerAware.cache_mount_accessible? = true)

  • If running in Docker without cache mount: uses workspace-relative cache (./.docopslab-cache/) which persists with the project

  • If running natively: uses ~/.cache/docopslab/ via XDG_CACHE_HOME

Callers should use this module directly: Library.fetch!, Library.resolve(path), etc.

Defined Under Namespace

Modules: Cache, Fetch

Class Method Summary collapse

Class Method Details

.available?Boolean

True if a library is available via cache or local_path fallback.

Returns:

  • (Boolean)


140
141
142
143
144
145
# File 'lib/docopslab/dev/library.rb', line 140

def available?
  return true if Cache.available?

  lp = Dev.load_manifest&.dig('library', 'local_path')
  !!(lp && File.exist?(File.join(lp, 'catalog.json')))
end

.cached_pathObject



102
103
104
# File 'lib/docopslab/dev/library.rb', line 102

def cached_path
  Cache.current_path
end

.ensure_available!Object

Ensure the library is available, auto-fetching if necessary. Returns true if available after the call; raises on failure.



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/docopslab/dev/library.rb', line 149

def ensure_available!
  return true if available?

  puts '📥 Library cache not found; fetching now...'
  ok = fetch!
  return true if ok && available?

  lp = Dev.load_manifest&.dig('library', 'local_path')
  if lp && Dir.exist?(lp)
    warn "⚠️  Remote fetch failed; using local_path fallback: #{lp}"
    return true
  end

  raise 'Library unavailable. Run `bundle exec rake labdev:sync:library` to fetch it.'
end

.fetch!(config = nil) ⇒ Object



27
28
29
30
# File 'lib/docopslab/dev/library.rb', line 27

def fetch! config=nil
  config ||= library_config_from_manifest
  with_cache_root(config) { Fetch.call(config) }
end

Compare manifest catalog entries against the cached library files Falls back to an on-repo local path if provided in the manifest



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/docopslab/dev/library.rb', line 200

def print_catalog_comparison manifest = nil
  manifest ||= Dev.load_manifest
  lib_cfg = manifest && manifest['library']

  if lib_cfg.nil? || lib_cfg.empty?
    puts "ℹ️  No `library` block found in #{Dev.manifest_path} (or it's empty)."
    return
  end

  catalog = lib_cfg.dig('catalog', 'overrides') || lib_cfg['catalog'] || lib_cfg['catalog_overrides']

  unless catalog && !catalog.empty?
    puts 'ℹ️  No catalog overrides found in manifest.library.catalog; nothing to compare.'
    return
  end

  puts '🔎 Comparing manifest catalog entries to cached library files...'

  entries = []
  case catalog
  when Array
    entries = catalog
  when Hash
    catalog.each do |k, v|
      entries << if v.is_a?(String)
                   v
                 elsif v.is_a?(Hash) && v['path']
                   v['path']
                 else
                   k
                 end
    end
  else
    puts "⚠️  Unrecognized catalog format: #{catalog.class}. Skipping detailed compare."
    return
  end

  missing = []
  present = []

  entries.each do |rel_path|
    rel = rel_path.to_s.sub(%r{^/}, '')
    resolved = resolve(rel)

    # Fallback to on-repo local path if provided
    if resolved.nil? && lib_cfg['local_path']
      repo_local = File.join(Dir.pwd, lib_cfg['local_path'].to_s, rel)
      resolved = File.exist?(repo_local) ? repo_local : nil
    end

    if resolved
      present << { path: rel, full: resolved }
    else
      missing << rel
    end
  end

  if present.any?
    puts "✅ Found #{present.size} catalog entries in the cache or local path:"
    present.each do |p|
      puts "  - #{p[:path]} -> #{p[:full]}"
    end
  end

  if missing.any?
    puts "❌ Missing #{missing.size} catalog entries in the cache/local path:"
    missing.each do |m|
      puts "  - #{m}"
    end
  else
    puts '✅ All catalog entries present in cache/local path.'
  end
end


179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/docopslab/dev/library.rb', line 179

def print_status
  s = status
  if s[:available]
    puts "📚 Library cache: #{s[:cache_path]}"
    puts "   Version    : #{s[:version] || '(unknown)'}"
    puts "   Ref        : #{s[:ref] || '(unknown)'}"
    puts "   Generated  : #{s[:generated_at] || '(unknown)'}"
    puts "   Previous   : #{s[:has_previous] ? 'yes' : 'none'}"
  else
    puts "⚠️  No library cache found at #{s[:cache_path]}"
    lp = Dev.load_manifest&.dig('library', 'local_path')
    if lp && File.exist?(File.join(lp, 'catalog.json'))
      puts "   Local path : #{File.expand_path(lp)} (active fallback)"
    else
      puts '   Run `bundle exec rake labdev:sync:library` to fetch.'
    end
  end
end

.resolve(relative_path) ⇒ Object

Returns the absolute path to a cached file, or nil if absent. Resolution order:

1. XDG host cache (~/.cache/docopslab/dev/library/current/)
2. local_path from manifest (dev/monorepo fallback, e.g. .library/)


123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/docopslab/dev/library.rb', line 123

def resolve relative_path
  if Cache.available?
    full_path = File.join(Cache.current_path, relative_path)
    return full_path if File.exist?(full_path)
  end

  # local_path fallback for monorepo dev and offline use
  lp = Dev.load_manifest&.dig('library', 'local_path')
  if lp
    local_full = File.expand_path(File.join(lp, relative_path))
    return local_full if File.exist?(local_full)
  end

  nil
end

.rollback!Object



169
170
171
172
173
174
175
176
177
# File 'lib/docopslab/dev/library.rb', line 169

def rollback!
  if Cache.rollback!
    puts "✅ Library rolled back to previous snapshot at #{Cache.current_path}"
    true
  else
    warn '⚠️  No previous library snapshot available for rollback.'
    false
  end
end

.rootObject

Returns the effective library root directory (nil if unavailable). Does not auto-fetch; call ensure_available! first if needed. Resolution order mirrors resolve():

1. XDG host cache  2. local_path from manifest


110
111
112
113
114
115
116
117
# File 'lib/docopslab/dev/library.rb', line 110

def root
  return Cache.current_path if Cache.available?

  lp = Dev.load_manifest&.dig('library', 'local_path')
  return File.expand_path(lp) if lp && File.exist?(File.join(lp, 'catalog.json'))

  nil
end

.stage!(source_path: nil) ⇒ Object

Copy a local library directory into the host cache and sync content to manifest-configured paths. Intended for development workflows where assets live in the lab monorepo (.library/ or library/current/) and have not yet been published to the remote branch.

Resolution order for source_path:

1. Explicit argument (task arg or direct call)
2. manifest +library.local_path+ (resolved relative to cwd)
3. .library/ in the current working directory
4. ../lab/.library/ relative to cwd (downstream-project fallback)

A minimal catalog.json is generated into a staging copy if the source directory does not already contain one.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/docopslab/dev/library.rb', line 72

def stage! source_path: nil
  resolved = resolve_stage_source(source_path)
  unless resolved
    warn '⚠️  No local library path found. ' \
         "Pass a path, or set library.local_path in #{Dev::MANIFEST_PATH}."
    return false
  end

  puts "📦 Staging local library from #{resolved}..."

  Dir.mktmpdir('docopslab-stage-') do |tmpdir|
    dest = File.join(tmpdir, 'stage')
    FileUtils.cp_r(resolved, dest)
    ensure_catalog!(dest)
    Cache.write!(dest)
  end

  puts "✅ Local library staged to #{Cache.current_path}"

  context = Dev
  SyncOps.sync_config_files(context)
  SyncOps.sync_docs(context, force: true)
  SyncOps.sync_templates(context, force: true)
  SyncOps.sync_scripts(context)
  true
rescue StandardError => e
  warn "⚠️  Stage failed: #{e.message}"
  false
end

.statusObject



165
166
167
# File 'lib/docopslab/dev/library.rb', line 165

def status
  Cache.status
end

.sync!(force: false) ⇒ Object

Fetch the library if the cache is absent or stale, then sync all manifest-driven content (docs, config files, templates, scripts) to local paths. This is the main entry point for ‘labdev:sync:library`.



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/docopslab/dev/library.rb', line 35

def sync! force: false
  config = library_config_from_manifest
  with_cache_root(config) do
    if local_path_active?(config)
      puts "📚 Using local library at #{File.expand_path(config['local_path'])}"
    elsif !force && Cache.available? && sha_current?(config)
      puts "\u2705 Library cache is up to date (#{Cache.stored_head&.slice(0, 8)})"
    else
      puts Cache.available? ? '🔄 Library has updates; refreshing...' : '📥 Library cache not found; fetching...'
      ok = Fetch.call(config)
      unless ok
        warn '⚠️  Library fetch failed. Using existing cache if available.'
        raise 'Library unavailable.' unless available?
      end
    end

    context = Dev
    SyncOps.sync_config_files(context)
    SyncOps.sync_docs(context, force: force)
    SyncOps.sync_templates(context, force: force)
    SyncOps.sync_scripts(context)
  end
end