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.
- checksums.yaml +4 -4
- data/README.adoc +645 -318
- data/docopslab-dev.gemspec +2 -3
- data/docs/agent/index.md +4 -8
- data/docs/agent/misc/bash-styles.md +470 -0
- data/docs/agent/missions/conduct-release.md +161 -87
- data/docs/agent/missions/setup-new-project.md +228 -134
- data/docs/agent/roles/devops-release-engineer.md +60 -17
- data/docs/agent/roles/docops-engineer.md +84 -20
- data/docs/agent/roles/planner-architect.md +22 -0
- data/docs/agent/roles/product-engineer.md +63 -15
- data/docs/agent/roles/product-manager.md +57 -24
- data/docs/agent/roles/project-manager.md +48 -12
- data/docs/agent/roles/qa-testing-engineer.md +48 -14
- data/docs/agent/roles/tech-docs-manager.md +63 -17
- data/docs/agent/roles/tech-writer.md +68 -14
- data/docs/agent/skills/asciidoc.md +65 -238
- data/docs/agent/skills/bash-cli-dev.md +135 -0
- data/docs/agent/skills/code-commenting.md +143 -106
- data/docs/agent/skills/fix-broken-links.md +145 -100
- data/docs/agent/skills/fix-jekyll-asciidoc-build-errors.md +1 -10
- data/docs/agent/skills/fix-spelling-issues.md +0 -3
- data/docs/agent/skills/git.md +69 -34
- data/docs/agent/skills/github-issues.md +110 -71
- data/docs/agent/skills/rake-cli-dev.md +1 -1
- data/docs/agent/skills/readme-driven-dev.md +1 -0
- data/docs/agent/skills/release-history.md +1 -7
- data/docs/agent/skills/ruby.md +18 -7
- data/docs/agent/skills/schemagraphy-sgyml.md +3 -0
- data/docs/agent/skills/tests-running.md +22 -14
- data/docs/agent/skills/tests-writing.md +51 -28
- data/docs/agent/skills/write-the-docs.md +71 -9
- data/docs/agent/topics/common-project-paths.md +122 -70
- data/docs/agent/topics/dev-tooling-usage.md +70 -77
- data/docs/agent/topics/devops-ci-cd.md +3 -1
- data/docs/agent/topics/product-docs-deployment.md +18 -12
- data/docs/library-readme.adoc +39 -0
- data/lib/docopslab/dev/cast_ops.rb +199 -0
- data/lib/docopslab/dev/config_manager.rb +6 -6
- data/lib/docopslab/dev/data_utils.rb +42 -0
- data/lib/docopslab/dev/file_utils.rb +18 -7
- data/lib/docopslab/dev/git_branch.rb +201 -0
- data/lib/docopslab/dev/git_hooks.rb +17 -11
- data/lib/docopslab/dev/initializer.rb +13 -4
- data/lib/docopslab/dev/library/cache.rb +167 -0
- data/lib/docopslab/dev/library/fetch.rb +209 -0
- data/lib/docopslab/dev/library.rb +328 -0
- data/lib/docopslab/dev/linters.rb +63 -12
- data/lib/docopslab/dev/manifest.rb +28 -0
- data/lib/docopslab/dev/paths.rb +0 -17
- data/lib/docopslab/dev/script_manager.rb +12 -6
- data/lib/docopslab/dev/skim.rb +109 -0
- data/lib/docopslab/dev/spell_check.rb +2 -2
- data/lib/docopslab/dev/sync_ops.rb +94 -33
- data/lib/docopslab/dev/tasks.rb +58 -18
- data/lib/docopslab/dev/version.rb +1 -1
- data/lib/docopslab/dev.rb +75 -35
- data/specs/data/default-manifest.yml +15 -5
- data/specs/data/library-index.yml +22 -0
- data/specs/data/manifest-schema.yaml +142 -4
- data/specs/data/tasks-def.yml +122 -10
- metadata +28 -39
- data/assets/config-packs/actionlint/base.yml +0 -13
- data/assets/config-packs/actionlint/project.yml +0 -13
- data/assets/config-packs/htmlproofer/base.yml +0 -27
- data/assets/config-packs/htmlproofer/project.yml +0 -25
- data/assets/config-packs/rubocop/base.yml +0 -130
- data/assets/config-packs/rubocop/project.yml +0 -8
- data/assets/config-packs/shellcheck/base.shellcheckrc +0 -14
- data/assets/config-packs/subtxt/ai-asciidoc-antipatterns.sub.txt +0 -11
- data/assets/config-packs/vale/asciidoc/ExplicitSectionIDs.yml +0 -8
- data/assets/config-packs/vale/asciidoc/ExtraLineBeforeLevel1.yml +0 -7
- data/assets/config-packs/vale/asciidoc/OneSentencePerLine.yml +0 -8
- data/assets/config-packs/vale/asciidoc/PreferSourceBlocks.yml +0 -8
- data/assets/config-packs/vale/asciidoc/ProperAdmonitions.yml +0 -8
- data/assets/config-packs/vale/asciidoc/ProperDLs.yml +0 -7
- data/assets/config-packs/vale/asciidoc/UncleanListStart.yml +0 -8
- data/assets/config-packs/vale/authoring/ButParagraph.yml +0 -8
- data/assets/config-packs/vale/authoring/ExNotEg.yml +0 -8
- data/assets/config-packs/vale/authoring/LiteralTerms.yml +0 -20
- data/assets/config-packs/vale/authoring/Spelling.yml +0 -679
- data/assets/config-packs/vale/base.ini +0 -38
- data/assets/config-packs/vale/config/scripts/ExplicitSectionIDs.tengo +0 -56
- data/assets/config-packs/vale/config/scripts/ExtraLineBeforeLevel1.tengo +0 -121
- data/assets/config-packs/vale/config/scripts/OneSentencePerLine.tengo +0 -53
- data/assets/config-packs/vale/project.ini +0 -5
- data/assets/hooks/pre-commit +0 -63
- data/assets/hooks/pre-push +0 -72
- data/assets/scripts/adoc_section_ids.rb +0 -50
- data/assets/scripts/build-common.sh +0 -193
- data/assets/scripts/build-docker.sh +0 -64
- data/assets/scripts/build.sh +0 -56
- data/assets/scripts/parse_jekyll_asciidoc_logs.rb +0 -467
- data/assets/templates/Gemfile +0 -7
- data/assets/templates/Rakefile +0 -3
- data/assets/templates/gitignore +0 -69
- data/assets/templates/jekyll-asciidoc-fix.prompt.yml +0 -17
- data/assets/templates/spellcheck.prompt.yml +0 -16
- 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
|