ace-handbook 0.32.0 → 0.33.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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a640260822cd7518266797ae62628ae62a4cae9f6a96ca7604ed274245ac2915
|
|
4
|
+
data.tar.gz: 9f7e5b5515524a15a2547a874f0324961bb6e38489026d16f316bf2358bed2b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b67c9b8c9ad1c7a21121cd2e8694da47bfd545b2142dee84dfbcff70917049a0cb634e2194b938079db3f3a0d128ed3de1ca7e6b65585e97900c669a86ffd1c2
|
|
7
|
+
data.tar.gz: ad70a65d103696d260014b279073266ef0beab0e79a2d54ac4e575bc3217a5c1d290623a132e9bebce9192e49b7e89c609b34944ea50c47f3889f2d724fafc45
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.33.0] - 2026-09-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Provider manifests may declare an optional `extensions_dir`; `ace-handbook sync` now projects extension assets from `ace-handbook-integration-*` packages (`handbook/extensions/**`) into that directory (idempotent copies; prunes only stale paths recorded in the `.ace-handbook-projection.json` receipt, leaving user-authored extensions untouched). Destinations are validated before anything is written; a failed projection rolls back overwritten **and** pruned assets so the previous installation stays complete and loadable, and the retry needs no manual cleanup. Sync summary output gains a `; extensions <dir> (N files, U updated, R removed)` segment when a provider projects extensions. `ProviderRegistry` exposes `extensions_dir(provider)` and `package_root(provider)`.
|
|
14
|
+
|
|
10
15
|
## [0.32.0] - 2026-09-27
|
|
11
16
|
|
|
12
17
|
### Added
|
|
@@ -38,6 +38,15 @@ module Ace
|
|
|
38
38
|
manifest(provider)["prompts_dir"]
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
def extensions_dir(provider)
|
|
42
|
+
manifest(provider)["extensions_dir"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def package_root(provider)
|
|
46
|
+
manifest_dir = File.dirname(manifest(provider).fetch("_manifest_path"))
|
|
47
|
+
File.expand_path("../../..", manifest_dir)
|
|
48
|
+
end
|
|
49
|
+
|
|
41
50
|
def manifests
|
|
42
51
|
@manifests ||= begin
|
|
43
52
|
all_paths = local_manifest_paths + installed_manifest_paths
|
|
@@ -26,6 +26,7 @@ module Ace
|
|
|
26
26
|
"(#{result[:projected_skills]} skills, #{result[:updated_files]} updated, " \
|
|
27
27
|
"#{result[:removed_entries]} removed)"
|
|
28
28
|
line += format_prompts_summary(result) if result.key?(:relative_prompts_dir)
|
|
29
|
+
line += format_extensions_summary(result) if result.key?(:relative_extensions_dir)
|
|
29
30
|
puts line
|
|
30
31
|
end
|
|
31
32
|
print_inventory_summary(results)
|
|
@@ -41,6 +42,12 @@ module Ace
|
|
|
41
42
|
"#{result[:removed_prompt_entries]} removed)"
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
def format_extensions_summary(result)
|
|
46
|
+
"; extensions #{result[:relative_extensions_dir]} " \
|
|
47
|
+
"(#{result[:projected_extensions]} files, #{result[:updated_extension_files]} updated, " \
|
|
48
|
+
"#{result[:removed_extension_entries]} removed)"
|
|
49
|
+
end
|
|
50
|
+
|
|
44
51
|
def print_inventory_summary(results)
|
|
45
52
|
source_breakdown = results.first&.fetch(:source_breakdown, {})
|
|
46
53
|
return if source_breakdown.nil? || source_breakdown.empty?
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "tempfile"
|
|
4
7
|
require "yaml"
|
|
5
8
|
|
|
6
9
|
module Ace
|
|
@@ -8,6 +11,7 @@ module Ace
|
|
|
8
11
|
module Organisms
|
|
9
12
|
class ProviderSyncer
|
|
10
13
|
PROJECTION_SOURCE_PREFIX = "ace-handbook-integration-"
|
|
14
|
+
EXTENSION_RECEIPT_NAME = ".ace-handbook-projection.json"
|
|
11
15
|
|
|
12
16
|
attr_reader :project_root, :registry, :inventory, :prompt_inventory, :config
|
|
13
17
|
|
|
@@ -36,11 +40,12 @@ module Ace
|
|
|
36
40
|
private
|
|
37
41
|
|
|
38
42
|
def sync_provider(provider, skills:, source_breakdown:)
|
|
39
|
-
|
|
43
|
+
result = sync_skills(provider, skills: skills, source_breakdown: source_breakdown)
|
|
40
44
|
prompts_result = sync_prompts(provider)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
result = result.merge(prompts_result) unless prompts_result.nil?
|
|
46
|
+
extensions_result = sync_extensions(provider)
|
|
47
|
+
result = result.merge(extensions_result) unless extensions_result.nil?
|
|
48
|
+
result
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def sync_skills(provider, skills:, source_breakdown:)
|
|
@@ -171,6 +176,258 @@ module Ace
|
|
|
171
176
|
stale.size
|
|
172
177
|
end
|
|
173
178
|
|
|
179
|
+
# Extension assets project from a package's handbook/extensions/ tree
|
|
180
|
+
# into the provider manifest's extensions_dir. The target directory may
|
|
181
|
+
# hold user-authored extensions, so pruning is receipt-based: only files
|
|
182
|
+
# recorded in the projection receipt from a previous sync are removed.
|
|
183
|
+
def sync_extensions(provider)
|
|
184
|
+
extensions_dir = registry.extensions_dir(provider)
|
|
185
|
+
return nil if extensions_dir.nil? || extensions_dir.to_s.empty?
|
|
186
|
+
|
|
187
|
+
source_dir = File.join(registry.package_root(provider), "handbook", "extensions")
|
|
188
|
+
expected = extension_source_files(source_dir)
|
|
189
|
+
output_dir = File.join(project_root, extensions_dir)
|
|
190
|
+
FileUtils.mkdir_p(output_dir)
|
|
191
|
+
|
|
192
|
+
receipt = read_projection_receipt(output_dir, provider)
|
|
193
|
+
# Ownership requires a valid receipt from this projection explicitly
|
|
194
|
+
# listing the path. A missing, symlinked, or corrupt receipt leaves
|
|
195
|
+
# ownership unknown: any conflicting destination file is refused so
|
|
196
|
+
# user-authored extensions can never be overwritten on a guess.
|
|
197
|
+
owned_paths = receipt&.fetch("files", []) || []
|
|
198
|
+
reject_unowned_collisions(output_dir, expected.keys, owned_paths)
|
|
199
|
+
|
|
200
|
+
# Pruning happens only after the replacement projection committed:
|
|
201
|
+
# a failed upgrade must leave the previous installation usable.
|
|
202
|
+
stale_paths = receipt&.fetch("files", [])&.-(expected.keys) || []
|
|
203
|
+
updated_files = 0
|
|
204
|
+
|
|
205
|
+
# Resolve and validate every destination before writing anything: a
|
|
206
|
+
# failure mid-copy must never leave half a projection behind (files
|
|
207
|
+
# written before a later validation error would be unowned and wedge
|
|
208
|
+
# every retry).
|
|
209
|
+
output_paths = expected.each_with_object({}) do |(relative_path, source_path), map|
|
|
210
|
+
map[relative_path] = validate_projection_destination(output_dir, relative_path, extensions_dir)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
newly_created = []
|
|
214
|
+
saved_originals = {}
|
|
215
|
+
failed_prunes = []
|
|
216
|
+
begin
|
|
217
|
+
output_paths.each do |relative_path, output_path|
|
|
218
|
+
source_path = expected.fetch(relative_path)
|
|
219
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
220
|
+
created = !File.exist?(output_path)
|
|
221
|
+
# Register before copying: a copy that fails partway (disk
|
|
222
|
+
# exhaustion) must still roll back the partial file. Overwritten
|
|
223
|
+
# files are receipt-owned, so rollback restores their previous
|
|
224
|
+
# content instead of deleting them.
|
|
225
|
+
saved_originals[output_path] = File.binread(output_path) unless created
|
|
226
|
+
newly_created << output_path if created
|
|
227
|
+
next if !created && FileUtils.compare_file(source_path, output_path)
|
|
228
|
+
|
|
229
|
+
FileUtils.cp(source_path, output_path)
|
|
230
|
+
updated_files += 1
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
removed_entries = prune_stale_extension_files(output_dir, stale_paths, failed_prunes, saved_originals)
|
|
234
|
+
# Stale files whose removal failed keep their ownership until a
|
|
235
|
+
# later sync manages to delete them.
|
|
236
|
+
write_projection_receipt(output_dir, provider, expected.keys + failed_prunes)
|
|
237
|
+
rescue
|
|
238
|
+
# Roll back so a retry never finds its own partial installation
|
|
239
|
+
# standing in the way as an unowned collision, and restore the
|
|
240
|
+
# previous content of overwritten and pruned files — the previous
|
|
241
|
+
# installation must stay complete and loadable.
|
|
242
|
+
newly_created.each do |path|
|
|
243
|
+
FileUtils.rm_f(path)
|
|
244
|
+
rescue
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
saved_originals.each do |path, content|
|
|
248
|
+
# Pruned assets restore after pruning emptied and removed their
|
|
249
|
+
# directories; a path swapped to a symlink mid-sync is never
|
|
250
|
+
# written through.
|
|
251
|
+
next if File.symlink?(path)
|
|
252
|
+
|
|
253
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
254
|
+
File.binwrite(path, content)
|
|
255
|
+
rescue
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
newly_created.each do |path|
|
|
259
|
+
remove_empty_parent_dirs(File.dirname(path), output_dir)
|
|
260
|
+
end
|
|
261
|
+
raise
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
{
|
|
265
|
+
relative_extensions_dir: extensions_dir,
|
|
266
|
+
projected_extensions: expected.size,
|
|
267
|
+
updated_extension_files: updated_files,
|
|
268
|
+
removed_extension_entries: removed_entries
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The destination directory may hold user-authored extensions, so a
|
|
273
|
+
# destination file that exists before the first ACE projection (or one
|
|
274
|
+
# the receipt does not own) is never overwritten: the sync fails with
|
|
275
|
+
# a visible error instead of destroying user files.
|
|
276
|
+
def reject_unowned_collisions(output_dir, expected_relative_paths, owned_paths)
|
|
277
|
+
expected_relative_paths.each do |relative_path|
|
|
278
|
+
next if owned_paths.include?(relative_path)
|
|
279
|
+
|
|
280
|
+
candidate = safe_projection_path(output_dir, relative_path)
|
|
281
|
+
next if candidate.nil? || !File.exist?(candidate)
|
|
282
|
+
|
|
283
|
+
raise StandardError,
|
|
284
|
+
"refusing to overwrite existing file #{candidate}; it is not claimed by a valid projection receipt " \
|
|
285
|
+
"(missing, symlinked, or corrupt receipts claim nothing). Move or remove the file — or repair or remove " \
|
|
286
|
+
"#{File.join(output_dir, EXTENSION_RECEIPT_NAME)} — then rerun `ace-handbook sync`."
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Resolve and fully validate a destination before any file is
|
|
291
|
+
# written: every ancestor must be a directory or creatable (no
|
|
292
|
+
# regular files, no symlinks), so a mid-copy failure can never leave
|
|
293
|
+
# a half-installed projection that wedges retries.
|
|
294
|
+
def validate_projection_destination(output_dir, relative_path, extensions_dir)
|
|
295
|
+
segments = relative_path.split(File::SEPARATOR)
|
|
296
|
+
if segments.empty? || segments.any? { |segment| segment.empty? || segment == "." || segment == ".." }
|
|
297
|
+
raise StandardError, "cannot project #{relative_path.inspect}: invalid destination path"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
current = Pathname.new(File.realpath(output_dir))
|
|
301
|
+
segments.each do |segment|
|
|
302
|
+
current = current.join(segment)
|
|
303
|
+
if File.symlink?(current.to_s)
|
|
304
|
+
raise StandardError,
|
|
305
|
+
"cannot project #{relative_path} into #{extensions_dir}: a symlinked path component would escape the projection directory"
|
|
306
|
+
end
|
|
307
|
+
next if segments.last == segment
|
|
308
|
+
|
|
309
|
+
if !current.directory? && File.exist?(current.to_s)
|
|
310
|
+
raise StandardError,
|
|
311
|
+
"cannot project #{relative_path} into #{extensions_dir}: #{current} exists and is not a directory"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
if File.directory?(current.to_s)
|
|
315
|
+
# Receipt ownership of a path never authorizes writing through a
|
|
316
|
+
# directory planted at that path — cp would reinterpret the
|
|
317
|
+
# destination as a container and overwrite unowned content.
|
|
318
|
+
raise StandardError,
|
|
319
|
+
"cannot project #{relative_path} into #{extensions_dir}: #{current} exists and is a directory"
|
|
320
|
+
end
|
|
321
|
+
current.to_s
|
|
322
|
+
rescue Errno::ENOENT
|
|
323
|
+
raise StandardError, "cannot project #{relative_path} into #{extensions_dir}: projection directory vanished"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def extension_source_files(source_dir)
|
|
327
|
+
return {} unless Dir.exist?(source_dir)
|
|
328
|
+
|
|
329
|
+
source_root = Pathname.new(source_dir)
|
|
330
|
+
Dir.glob(File.join(source_dir, "**", "*"))
|
|
331
|
+
.select { |path| File.file?(path) }
|
|
332
|
+
.to_h { |path| [Pathname.new(path).relative_path_from(source_root).to_s, path] }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def prune_stale_extension_files(output_dir, stale_relative_paths, failed_prunes, rollback_ledger)
|
|
336
|
+
stale = stale_relative_paths
|
|
337
|
+
removed = 0
|
|
338
|
+
stale.each do |relative_path|
|
|
339
|
+
contained = safe_projection_path(output_dir, relative_path)
|
|
340
|
+
next if contained.nil?
|
|
341
|
+
next unless File.file?(contained)
|
|
342
|
+
|
|
343
|
+
begin
|
|
344
|
+
# Each pruned asset joins the rollback ledger before deletion:
|
|
345
|
+
# if publication later fails, restoring only overwritten files
|
|
346
|
+
# would leave the retained previous entrypoint importing a
|
|
347
|
+
# module that no longer exists.
|
|
348
|
+
rollback_ledger[contained] = File.binread(contained)
|
|
349
|
+
FileUtils.rm(contained)
|
|
350
|
+
removed += 1
|
|
351
|
+
remove_empty_parent_dirs(File.dirname(contained), output_dir)
|
|
352
|
+
rescue
|
|
353
|
+
# Keep ownership of the undeletable file: the next sync retries
|
|
354
|
+
# the removal instead of forgetting the file belongs to ACE.
|
|
355
|
+
failed_prunes << relative_path
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
removed
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Receipt files are projected data, so treat them as untrusted, and
|
|
362
|
+
# destination paths are resolved on the real filesystem: only plain
|
|
363
|
+
# relative paths whose every component stays inside output_dir — with
|
|
364
|
+
# no symlinked component, which could point anywhere — may be written
|
|
365
|
+
# or pruned.
|
|
366
|
+
def safe_projection_path(output_dir, relative_path)
|
|
367
|
+
return nil unless relative_path.is_a?(String)
|
|
368
|
+
return nil if relative_path.include?("\0")
|
|
369
|
+
|
|
370
|
+
segments = relative_path.split(File::SEPARATOR)
|
|
371
|
+
return nil if segments.empty? || segments.any? { |segment| segment.empty? || segment == "." || segment == ".." }
|
|
372
|
+
|
|
373
|
+
current = Pathname.new(File.realpath(output_dir))
|
|
374
|
+
segments.each do |segment|
|
|
375
|
+
current = current.join(segment)
|
|
376
|
+
return nil if File.symlink?(current.to_s)
|
|
377
|
+
end
|
|
378
|
+
current.to_s
|
|
379
|
+
rescue Errno::ENOENT
|
|
380
|
+
nil
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def read_projection_receipt(output_dir, provider)
|
|
384
|
+
receipt_path = File.join(output_dir, EXTENSION_RECEIPT_NAME)
|
|
385
|
+
return nil if File.symlink?(receipt_path)
|
|
386
|
+
return nil unless File.file?(receipt_path)
|
|
387
|
+
|
|
388
|
+
receipt = JSON.parse(File.read(receipt_path))
|
|
389
|
+
return nil unless receipt.is_a?(Hash) && receipt["files"].is_a?(Array)
|
|
390
|
+
# A receipt from another projector (or without provenance) claims
|
|
391
|
+
# nothing: only our own source marker authorizes overwrites and
|
|
392
|
+
# pruning.
|
|
393
|
+
return nil unless receipt["source"] == "#{PROJECTION_SOURCE_PREFIX}#{provider}"
|
|
394
|
+
|
|
395
|
+
receipt
|
|
396
|
+
rescue JSON::ParserError
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def write_projection_receipt(output_dir, provider, relative_paths)
|
|
401
|
+
receipt_path = File.join(output_dir, EXTENSION_RECEIPT_NAME)
|
|
402
|
+
# Create the temp file exclusively in the destination directory: a
|
|
403
|
+
# predictable, plainly-opened temp path could follow a planted
|
|
404
|
+
# symlink and truncate a file outside the projection. The rename
|
|
405
|
+
# then replaces any symlink at the receipt path itself.
|
|
406
|
+
temp = Tempfile.create([".ace-handbook-projection", ".tmp"], output_dir)
|
|
407
|
+
begin
|
|
408
|
+
temp.write(JSON.pretty_generate(
|
|
409
|
+
"source" => "#{PROJECTION_SOURCE_PREFIX}#{provider}",
|
|
410
|
+
"files" => relative_paths.sort
|
|
411
|
+
))
|
|
412
|
+
temp.close
|
|
413
|
+
File.rename(temp.path, receipt_path)
|
|
414
|
+
ensure
|
|
415
|
+
temp.close
|
|
416
|
+
FileUtils.rm_f(temp.path)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def remove_empty_parent_dirs(dir, stop_dir)
|
|
421
|
+
until dir == stop_dir
|
|
422
|
+
FileUtils.rmdir(dir)
|
|
423
|
+
dir = File.dirname(dir)
|
|
424
|
+
end
|
|
425
|
+
rescue Errno::ENOTEMPTY, Errno::ENOENT
|
|
426
|
+
# Surviving siblings (or a vanished ancestor) stop the ascent; the
|
|
427
|
+
# sync must continue writing remaining assets and the receipt.
|
|
428
|
+
nil
|
|
429
|
+
end
|
|
430
|
+
|
|
174
431
|
# The prompts dir is shared with user-authored templates, so only files
|
|
175
432
|
# carrying an ACE integration provenance marker are ever pruned.
|
|
176
433
|
def prune_stale_prompt_files(output_dir, expected_template_names)
|
|
@@ -189,7 +446,7 @@ module Ace
|
|
|
189
446
|
|
|
190
447
|
frontmatter = YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: true)
|
|
191
448
|
frontmatter.is_a?(Hash) ? frontmatter["source"].to_s : ""
|
|
192
|
-
rescue
|
|
449
|
+
rescue
|
|
193
450
|
""
|
|
194
451
|
end
|
|
195
452
|
end
|
data/lib/ace/handbook/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-handbook
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.33.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-config
|