samagotchi 0.149.0 → 0.151.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/bundle-authoring.md +1 -1
  6. data/docs/cli.md +59 -24
  7. data/docs/configuration.md +6 -6
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/block-renderers.md +96 -0
  10. data/docs/internals/bridge.md +53 -9
  11. data/docs/internals/bundles.md +43 -14
  12. data/docs/internals/objectives.md +7 -2
  13. data/docs/internals/web-frontend.md +2 -2
  14. data/docs/internals/web-push.md +2 -2
  15. data/docs/internals/working-set.md +5 -2
  16. data/docs/memory.md +48 -10
  17. data/docs/plugins.md +49 -3
  18. data/docs/sessions.md +5 -4
  19. data/docs/testing.md +5 -0
  20. data/lib/samagotchi/atomic_file.rb +3 -0
  21. data/lib/samagotchi/block_renderers.rb +115 -0
  22. data/lib/samagotchi/bootstrap_command.rb +5 -2
  23. data/lib/samagotchi/bridge/card_places.rb +93 -0
  24. data/lib/samagotchi/bridge/card_store.rb +224 -82
  25. data/lib/samagotchi/bridge.rb +12 -4
  26. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  27. data/lib/samagotchi/bundle_command.rb +91 -68
  28. data/lib/samagotchi/bundler_env.rb +24 -0
  29. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  32. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  33. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  34. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  35. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  38. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  39. data/lib/samagotchi/chi_home.rb +4 -2
  40. data/lib/samagotchi/chi_shim.rb +2 -1
  41. data/lib/samagotchi/child_env.rb +10 -3
  42. data/lib/samagotchi/engine.rb +19 -1
  43. data/lib/samagotchi/git_env.rb +43 -0
  44. data/lib/samagotchi/guardrails/context.rb +2 -0
  45. data/lib/samagotchi/guardrails/targets.rb +4 -1
  46. data/lib/samagotchi/memory_bundle/asset_installer.rb +23 -3
  47. data/lib/samagotchi/memory_bundle/builder.rb +5 -3
  48. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  49. data/lib/samagotchi/memory_bundle/installed_bundle.rb +70 -6
  50. data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
  51. data/lib/samagotchi/memory_bundle/installer.rb +162 -52
  52. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  53. data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
  54. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  55. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  56. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  57. data/lib/samagotchi/memory_bundle/provenance.rb +182 -22
  58. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  59. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  61. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  62. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  63. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  64. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  65. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  67. data/lib/samagotchi/objectives_check.rb +37 -7
  68. data/lib/samagotchi/project_env.rb +3 -2
  69. data/lib/samagotchi/self_report.rb +2 -0
  70. data/lib/samagotchi/session_chain.rb +2 -7
  71. data/lib/samagotchi/session_commands.rb +15 -9
  72. data/lib/samagotchi/session_manager.rb +10 -7
  73. data/lib/samagotchi/steer.rb +17 -0
  74. data/lib/samagotchi/terminal_ui.rb +4 -4
  75. data/lib/samagotchi/testing/boundary.rb +1 -0
  76. data/lib/samagotchi/testing/minitest.rb +3 -0
  77. data/lib/samagotchi/testing/rspec.rb +3 -0
  78. data/lib/samagotchi/testing/sandbox.rb +3 -1
  79. data/lib/samagotchi/tools/delegate.rb +2 -1
  80. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  81. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  82. data/lib/samagotchi/turn_flow.rb +2 -1
  83. data/lib/samagotchi/update_command.rb +76 -28
  84. data/lib/samagotchi/update_hint.rb +4 -1
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +22 -12
  87. data/lib/samagotchi/web/auto_update.rb +8 -8
  88. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  89. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  90. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  91. data/lib/samagotchi/web/message_display.rb +3 -3
  92. data/lib/samagotchi/web/public/app.js +7 -3
  93. data/lib/samagotchi/web/public/block_frame.js +202 -0
  94. data/lib/samagotchi/web/public/block_host.js +502 -0
  95. data/lib/samagotchi/web/public/block_open.js +13 -0
  96. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  97. data/lib/samagotchi/web/public/block_source.js +37 -0
  98. data/lib/samagotchi/web/public/bubbles.js +11 -5
  99. data/lib/samagotchi/web/public/card.js +17 -10
  100. data/lib/samagotchi/web/public/cards_place.js +10 -5
  101. data/lib/samagotchi/web/public/claim_link.js +4 -2
  102. data/lib/samagotchi/web/public/details_motion.js +2 -1
  103. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  104. data/lib/samagotchi/web/public/index.html +20 -14
  105. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  106. data/lib/samagotchi/web/public/stage_model.js +5 -6
  107. data/lib/samagotchi/web/public/stage_view.js +8 -7
  108. data/lib/samagotchi/web/public/text_roots.js +6 -0
  109. data/lib/samagotchi/web/public/turn_block.js +34 -19
  110. data/lib/samagotchi/web/public/turn_html.js +4 -1
  111. data/lib/samagotchi/web/public/turn_model.js +1 -1
  112. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  113. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  114. data/lib/samagotchi/web/push/notifier.rb +7 -9
  115. data/lib/samagotchi/web/session_hub.rb +2 -1
  116. data/lib/samagotchi/web/session_show.rb +1 -1
  117. data/lib/samagotchi/web/update_ops.rb +2 -1
  118. data/lib/samagotchi/worker.rb +4 -2
  119. metadata +17 -1
@@ -34,6 +34,9 @@ module Samagotchi
34
34
 
35
35
  attr_reader :results, :warnings, :placeholder_warnings, :conflicts
36
36
 
37
+ # Each file this run left behind (FileStanding::Behind).
38
+ attr_reader :behind
39
+
37
40
  # prepared: the source already normalized (a PreparedSource, which the
38
41
  # Installer then owns and cleans up as its own); nil normalizes source.
39
42
  # shipped_dir: the bundles this chi ships (a bundle of their names
@@ -55,6 +58,9 @@ module Samagotchi
55
58
  @warnings = []
56
59
  @placeholder_warnings = []
57
60
  @conflicts = {}
61
+ @behind = []
62
+ @replaced = []
63
+ @outcomes = {}
58
64
  @overlays = []
59
65
  @trash = Trash.new(name)
60
66
  end
@@ -63,10 +69,16 @@ module Samagotchi
63
69
  # source (Installer#check_source), else nil.
64
70
  attr_reader :source_change
65
71
 
66
- # Where an upgrade moved the files the bundle no longer ships (nil:
67
- # none moved).
72
+ # Where this install moved the files the bundle no longer ships and
73
+ # copied the ones it overwrote (nil: none).
68
74
  def trash_dir = @trash.dir
69
75
 
76
+ # What it copied to the trash before overwriting, said once (nil: nothing).
77
+ def trash_copy_line = @trash.copy_line
78
+
79
+ # The files this run merged, the bundle's changes joined to the user's.
80
+ def merged_files = @results.select { |_, r| r[:status] == "merged" }.keys
81
+
70
82
  # The normalized source dir. An owned one (a git/zip/tar extract) is
71
83
  # removed after #run, or kept until #cleanup_source! when an upgrade
72
84
  # has conflicts (the agent step reads the incoming files); nil once
@@ -94,16 +106,7 @@ module Samagotchi
94
106
  check_source
95
107
  # A plain install over an installed bundle: summary points at upgrade.
96
108
  @reinstall = @existing && !@upgrade && !@force && !@dry_run
97
- install_memories
98
-
99
- installed = AssetInstaller.new(
100
- name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
101
- results: @results, warnings: @warnings, conflicts: @conflicts,
102
- force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev
103
- ).run
104
-
105
- # Files the previous version shipped and this one doesn't.
106
- prune_dropped_files(@existing.files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
109
+ installed = replace_files
107
110
  verify_memory_checksums if @manifest && @strict
108
111
  warn_missing_needs(@manifest) if @manifest
109
112
  if @manifest && !@dry_run
@@ -113,6 +116,7 @@ module Samagotchi
113
116
 
114
117
  [@bundle_dir, @manifest]
115
118
  ensure
119
+ note_merges
116
120
  # An owned source (git/zip/tar) is cleaned up here, unless an upgrade
117
121
  # kept conflicts: the agent step still needs the incoming files, so
118
122
  # the caller runs #cleanup_source! when it is done.
@@ -131,9 +135,10 @@ module Samagotchi
131
135
  lines << "Source changes: #{@source_change.join(" → ")}" if @source_change
132
136
  installed = @results.select { |_, r| r[:status].to_s == "installed" }.keys
133
137
  updated = @results.select { |_, r| r[:status].to_s == "updated" }.keys
134
- kept = @results.select { |_, r| %w[kept kept_pruned].include?(r[:status].to_s) }.keys
138
+ merged = merged_files
139
+ would_merge = @results.select { |_, r| r[:status].to_s == "would_merge" }.keys
140
+ kept = @results.select { |_, r| %w[kept keep kept_pruned].include?(r[:status].to_s) }.keys
135
141
  skipped = @results.select { |_, r| %w[skipped noop would_skip].include?(r[:status].to_s) }.keys
136
- conflicts = @results.select { |_, r| r[:status].to_s == "conflict" }.keys
137
142
  would_install = @results.select { |_, r| r[:status].to_s == "would_install" }.keys
138
143
  fast_forward = @results.select { |_, r| r[:status].to_s == "fast_forward" }.keys
139
144
  removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
@@ -143,11 +148,13 @@ module Samagotchi
143
148
  lines << "Updated: #{updated.join(", ")}" unless updated.empty?
144
149
  lines << "Would install: #{would_install.join(", ")}" unless would_install.empty?
145
150
  lines << "Fast-forward: #{fast_forward.join(", ")}" unless fast_forward.empty?
151
+ lines << FileStanding.merged_line(merged, @manifest&.version) unless merged.empty?
152
+ lines << FileStanding.merged_line(would_merge, @manifest&.version, dry_run: true) unless would_merge.empty?
146
153
  lines << "Removed (no longer in the bundle): #{removed.join(", ")}#{" (moved to #{trash_dir})" if trash_dir}" unless removed.empty?
147
154
  lines << "Would remove (no longer in the bundle): #{would_remove.join(", ")}" unless would_remove.empty?
148
155
  lines << "Kept (local edits preserved): #{kept.join(", ")}" unless kept.empty?
149
156
  lines << "Skipped: #{skipped.join(", ")}" unless skipped.empty?
150
- lines << "Conflicts: #{conflicts.join(", ")}" unless conflicts.empty?
157
+ lines << trash_copy_line if trash_copy_line
151
158
  lines.concat(@warnings) unless @warnings.empty?
152
159
  lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
153
160
  lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
@@ -157,6 +164,28 @@ module Samagotchi
157
164
 
158
165
  private
159
166
 
167
+ # The steps that replace files: memories, the bundle's own folder, then
168
+ # the files it dropped. The record isn't written yet, so a failed run is
169
+ # retried as the same upgrade.
170
+ # @return [AssetInstaller::Installed]
171
+ # @raise [InstallError] a file system error (a file it can't read, a
172
+ # .trash it can't write)
173
+ def replace_files
174
+ install_memories
175
+
176
+ installed = AssetInstaller.new(
177
+ name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
178
+ results: @results, warnings: @warnings, conflicts: @conflicts, trash: @trash,
179
+ force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev
180
+ ).run
181
+
182
+ # Files the previous version shipped and this one doesn't.
183
+ prune_dropped_files(@existing.files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
184
+ installed
185
+ rescue SystemCallError => e
186
+ raise InstallError, Trash.failure_message(e)
187
+ end
188
+
160
189
  # Who may install under this name (a manifest can take any name: a
161
190
  # repo calling itself guardrails). Sources compare by SourceIdentity
162
191
  # (a shipped dir by name, in any gem or checkout; a git URL without
@@ -314,8 +343,12 @@ module Samagotchi
314
343
  @owned << file_key if owned_before.include?(file_key)
315
344
  skip_existing(file_key, file_path, target_path, @target_scope, owned: owned_before.include?(file_key))
316
345
  else
346
+ # Only --force gets here with a file in place: a copy keeps it.
347
+ @replaced << file_key if File.exist?(target_path)
348
+ @trash.copy(target_path) if File.exist?(target_path) && !FileUtils.identical?(file_path, target_path)
317
349
  FileUtils.cp(file_path, target_path)
318
350
  @owned << file_key
351
+ @outcomes[file_key] = Merger::INSTALL
319
352
  @results[file_key] = { status: "installed" }
320
353
  update_target_index(@target_scope, target_path, file_key)
321
354
  end
@@ -338,30 +371,71 @@ module Samagotchi
338
371
  MemoryFiles.symlinks(@bundle_dir, @manifest).each { |name| @warnings << "#{name} is a symlink (not installed)" }
339
372
  end
340
373
 
341
- # An upgrade's 3-way merge of one file (Merger.classify against the
342
- # base the last install recorded); a conflict leaves the local file
343
- # and is recorded in @conflicts for the agent step.
374
+ # An upgrade's 3-way merge of one file (Merger::Upgrade against the
375
+ # base the last install recorded); a clean text merge replaces the file;
376
+ # a conflict leaves it and is recorded in @conflicts for the agent step.
344
377
  def merge_memory(file_key, file_path, target_path)
345
- base_path = @provenance.base_path(file_key)
346
- case Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
378
+ outcome = @outcomes[file_key] = upgrade_view.outcome(file_key)
379
+ case outcome.status
347
380
  when :install
348
381
  FileUtils.cp(file_path, target_path)
349
382
  @results[file_key] = { status: "installed" }
350
383
  when :fast_forward
384
+ save_marked_file(file_key, target_path)
351
385
  FileUtils.cp(file_path, target_path)
352
386
  @results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
353
387
  when :keep
354
- @results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
388
+ @results[file_key] = { status: "kept", reason: outcome == Merger::KEEP ? "bundle unchanged, local edits preserved" : "local edits already hold the bundle's changes" }
355
389
  when :noop
356
390
  @results[file_key] = { status: "skipped", reason: "already up to date" }
391
+ when :merged
392
+ if Merger.write_merged(target_path, outcome, @trash)
393
+ @results[file_key] = { status: "merged", reason: "the bundle's changes joined to local edits" }
394
+ else
395
+ note_conflict(file_key, @outcomes[file_key] = Merger::CONFLICT.with(merge: TextMerge.failed(TextMerge::RACED)))
396
+ end
357
397
  when :conflict
358
- @results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
359
- @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
360
- @warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
398
+ note_conflict(file_key, outcome)
399
+ else raise ArgumentError, "unknown outcome status #{outcome.status.inspect} for #{file_key}" # the base would move with nothing written
361
400
  end
362
401
  update_target_index(@target_scope, target_path, file_key)
363
402
  end
364
403
 
404
+ # The file stays as it is, with the old base and the mark, in FileStanding's
405
+ # words.
406
+ def note_conflict(file_key, outcome)
407
+ behind = upgrade_view.behind(file_key, outcome, @manifest&.version)
408
+ @behind << behind
409
+ @results[file_key] = { status: "conflict", reason: behind.state(dry_run: @dry_run) }
410
+ @conflicts[file_key] = upgrade_view.paths(file_key)
411
+ @warnings << "#{behind.line(dry_run: @dry_run)} (#{FileStanding::FORCE_TAKES})"
412
+ end
413
+
414
+ # What merged, on the record, a run that failed later too: the file holds the
415
+ # merge whichever way the run ended. Never fails the run.
416
+ def note_merges
417
+ return if merged_files.empty?
418
+
419
+ @provenance.note_merged(merged_files, version: @manifest&.version, trash: trash_dir)
420
+ rescue StandardError => e
421
+ @warnings << "merge not noted on the record (#{e.message})"
422
+ end
423
+
424
+ def upgrade_view
425
+ @upgrade_view ||= Merger::Upgrade.new(provenance: @provenance, target_dir: @target_dir, source_dir: @bundle_dir)
426
+ end
427
+
428
+ # A marked file that equals its base, and the update goes ahead: the base
429
+ # is known to be real (this chi's mark, or ShippedHistory), or no line of the
430
+ # file goes. A copy is kept, and it says so. (Merger::Upgrade#outcome stops
431
+ # an older chi's mark otherwise.)
432
+ def save_marked_file(file_key, target_path)
433
+ return unless @existing.files.dig(file_key, :conflict)
434
+
435
+ @trash.copy(target_path)
436
+ @warnings << "#{file_key} was marked as kept but matched its recorded base, so it took the update; your copy is in the trash"
437
+ end
438
+
365
439
  # A dry run's verdict for one file, nothing written: an upgrade
366
440
  # classifies as merge_memory would (its status is the classification).
367
441
  def preview_memory(file_key, file_path, target_path)
@@ -371,10 +445,10 @@ module Samagotchi
371
445
  end
372
446
 
373
447
  if @upgrade && @existing
374
- base_path = @provenance.base_path(file_key)
375
- classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
376
- @results[file_key] = { status: classification.to_s }
377
- @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
448
+ outcome = upgrade_view.outcome(file_key)
449
+ return note_conflict(file_key, outcome) if outcome.conflict?
450
+
451
+ @results[file_key] = { status: outcome.merged? ? "would_merge" : outcome.status.to_s }
378
452
  elsif FileUtils.identical?(file_path, target_path)
379
453
  @results[file_key] = { status: "skipped", reason: "already up to date" }
380
454
  else
@@ -383,14 +457,15 @@ module Samagotchi
383
457
  end
384
458
 
385
459
  # Strict mode: a memory written from the bundle whose sha256 differs
386
- # from its manifest's warns. One that wasn't copied (kept, skipped:
387
- # a local edit isn't a bad download) or was merged isn't checked.
460
+ # from its manifest's warns. One that wasn't copied (kept, skipped, a
461
+ # dry run's keep and would_skip: a local edit isn't a bad download) or
462
+ # was merged (merged, a dry run's would_merge) isn't checked.
388
463
  def verify_memory_checksums
389
464
  @bundle_md.each do |file_key|
390
465
  next if @conflicts.key?(file_key)
391
466
 
392
467
  status = @results[file_key] ? @results[file_key][:status].to_s : nil
393
- next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
468
+ next if %w[kept keep kept_pruned fast_forward updated skipped would_skip merged would_merge].include?(status)
394
469
 
395
470
  target_path = File.join(@target_dir, file_key)
396
471
  next unless File.exist?(target_path)
@@ -437,9 +512,11 @@ module Samagotchi
437
512
  plugin_file: installed.plugin_file,
438
513
  requires_chi: @manifest.requires_chi,
439
514
  needs: @manifest.needs,
440
- conflicts: @conflicts.keys,
515
+ marks: @behind + held_conflict_marks,
516
+ incoming_dir: @bundle_dir,
441
517
  scripts_files: installed.script_files,
442
518
  context_providers: @manifest.context_providers,
519
+ block_renderers: @manifest.block_renderers,
443
520
  file_descriptions: @manifest.file_descriptions,
444
521
  session_settings: @manifest.session_settings,
445
522
  session_setting_types: @manifest.session_setting_types,
@@ -447,34 +524,69 @@ module Samagotchi
447
524
  setting_types: @manifest.setting_types,
448
525
  dev: @dev
449
526
  )
527
+ # Replaced by --force: an earlier merge into it no longer says anything true.
528
+ @provenance.clear_merge_notes([], files: @replaced)
529
+ end
530
+
531
+ # The files a plain install skipped that are still marked conflict:
532
+ # Provenance#write rebuilds their entries, and a file it left alone has
533
+ # missed the bundle's change as much as before (its version may move on
534
+ # all the same). One with the incoming bytes has nothing left to miss.
535
+ # @return [Array<FileStanding::Behind>]
536
+ def held_conflict_marks
537
+ return [] if @upgrade || @existing.nil?
538
+
539
+ kept = @owned.select { |key| @results.dig(key, :reason) == "already exists" && @existing.files.dig(key, :conflict) }
540
+ kept.map { |key| held_mark(key) }
450
541
  end
451
542
 
452
- # file key => the file whose content becomes its base. A file the
453
- # upgrade left as the user has it (kept, noop, conflict, kept_pruned)
454
- # keeps its old base, so the next upgrade still sees the local edit;
455
- # the rest (installed, updated, skipped) take what is on disk now. A
456
- # file the bundle dropped but the user kept stays recorded too.
543
+ # The mark now speaks of this source's text, so what it says comes from that
544
+ # text too: what was missed recomputed, an old reason dropped. No merge ran,
545
+ # so one from an older chi, which can't name sections, keeps its own words.
546
+ def held_mark(key)
547
+ entry = @existing.files.fetch(key)
548
+ return @existing.behind_entry(key, entry).with(version: @manifest.version) if InstalledBundle.legacy_mark?(entry)
549
+
550
+ upgrade_view.behind(key, Merger::CONFLICT, @manifest.version)
551
+ rescue SystemCallError
552
+ FileStanding::Behind.new(file: key, version: @manifest.version)
553
+ end
554
+
555
+ # file key => the file whose content becomes its base: the one its
556
+ # Merger::Outcome names (next_base). A file the bundle dropped but the
557
+ # user kept stays recorded too, with the base it has.
457
558
  def upgrade_provenance_files
458
559
  files = {}
459
560
  @owned.each do |file_key|
460
- target_path = File.join(@target_dir, file_key)
461
- next unless File.exist?(target_path)
561
+ next unless File.exist?(File.join(@target_dir, file_key))
462
562
 
463
- status = @results[file_key] ? @results[file_key][:status].to_s : nil
464
- base_path = @provenance.base_path(file_key)
465
- files[file_key] = %w[kept noop conflict kept_pruned].include?(status) && File.exist?(base_path) ? base_path : target_path
563
+ files[file_key] = next_base(file_key, @outcomes.fetch(file_key))
466
564
  end
467
565
  @existing.files.each_key do |old_key_str|
468
566
  next if @bundle_md.include?(old_key_str)
469
567
  next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
470
568
 
471
- base_path = @provenance.base_path(old_key_str)
472
- src = File.exist?(base_path) ? base_path : File.join(@target_dir, old_key_str)
473
- files[old_key_str] = src if File.exist?(src)
569
+ files[old_key_str] = recorded_base(@provenance.base_path(old_key_str))
474
570
  end
475
571
  files
476
572
  end
477
573
 
574
+ # The file an Outcome's base names. nil for a kept file with no base:
575
+ # the user's file must not become one, or the next upgrade fast-forwards
576
+ # over it.
577
+ def next_base(file_key, outcome)
578
+ case outcome.base
579
+ when :recorded then recorded_base(@provenance.base_path(file_key))
580
+ when :on_disk then File.join(@target_dir, file_key)
581
+ when :incoming then File.join(@bundle_dir, file_key)
582
+ else raise ArgumentError, "unknown base #{outcome.base.inspect} for #{file_key}" # nil would drop the base silently
583
+ end
584
+ end
585
+
586
+ # +base_path+, nil when there is no base (Provenance#write's "keep the
587
+ # entry, without one").
588
+ def recorded_base(base_path) = File.exist?(base_path) ? base_path : nil
589
+
478
590
  # file key => its base's content source for a plain install. Skipped
479
591
  # for a local edit: keep the installed base, so the next upgrade still
480
592
  # sees the edit (not a fast-forward over it).
@@ -483,9 +595,8 @@ module Samagotchi
483
595
  target_path = File.join(@target_dir, file_key)
484
596
  next unless File.exist?(target_path)
485
597
 
486
- base_path = @provenance.base_path(file_key)
487
- kept = @existing && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
488
- map[file_key] = kept ? base_path : target_path
598
+ kept = @existing && @results.dig(file_key, :reason) == "already exists"
599
+ map[file_key] = kept ? recorded_base(@provenance.base_path(file_key)) : target_path
489
600
  end
490
601
  files.merge(dropped_owned_files)
491
602
  end
@@ -503,8 +614,7 @@ module Samagotchi
503
614
  next if @bundle_md.include?(file_key)
504
615
  next unless File.exist?(File.join(@target_dir, file_key))
505
616
 
506
- base_path = @provenance.base_path(file_key)
507
- files[file_key] = base_path if File.exist?(base_path)
617
+ files[file_key] = recorded_base(@provenance.base_path(file_key))
508
618
  end
509
619
  end
510
620
 
@@ -520,7 +630,7 @@ module Samagotchi
520
630
  return
521
631
  else
522
632
  @results[file_key] = { status: "skipped", reason: "already exists" }
523
- @warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
633
+ @warnings << "Skipped #{file_key} (already exists; #{FileStanding::FORCE_TAKES})"
524
634
  end
525
635
  update_target_index(scope, target_path, file_key, owned: owned) unless @dry_run
526
636
  end
@@ -548,7 +658,7 @@ module Samagotchi
548
658
  remove_target_index(scope, key)
549
659
  else
550
660
  @results[key] = { status: "kept_pruned", reason: "local edits preserved" }
551
- @warnings << "Kept #{key}: no longer in the bundle but edited locally (use --force to remove)"
661
+ @warnings << "Kept #{key}: no longer in the bundle but edited locally (#{FileStanding::FORCE_MOVES})"
552
662
  end
553
663
  end
554
664
  end
@@ -15,8 +15,9 @@ module Samagotchi
15
15
  # its own line, and they aren't listed again.
16
16
  module Listing
17
17
  # error is set (and the rest nil) when the provenance manifest.json
18
- # can't be read; includes is a profile's recorded members, else nil.
19
- Installed = Struct.new(:name, :version, :scope, :files, :installed_at, :upgrade, :error, :includes, keyword_init: true)
18
+ # can't be read; includes is a profile's recorded members, else nil;
19
+ # behind is its files that stay behind (InstalledBundle#behind_files).
20
+ Installed = Struct.new(:name, :version, :scope, :files, :installed_at, :upgrade, :error, :includes, :behind, keyword_init: true)
20
21
  # source is the name `chi bundle install` takes (the shipped dir's
21
22
  # name); includes is a profile's members ([] for a plain bundle).
22
23
  Shipped = Struct.new(:name, :source, :version, :description, :includes, keyword_init: true)
@@ -50,7 +51,7 @@ module Samagotchi
50
51
  Installed.new(name: name, version: bundle.version, scope: bundle.scope,
51
52
  files: bundle.files.size, installed_at: bundle.installed_at,
52
53
  upgrade: ship && newer?(ship.version, bundle.version) ? ship : nil,
53
- includes: bundle.includes)
54
+ includes: bundle.includes, behind: bundle.behind_files)
54
55
  end
55
56
  end
56
57
 
@@ -7,6 +7,8 @@ require_relative "../context_providers"
7
7
  require_relative "bundle_hook"
8
8
 
9
9
  module Samagotchi
10
+ autoload :BlockRenderers, File.expand_path("../block_renderers", __dir__)
11
+
10
12
  module MemoryBundle
11
13
  # Parses and validates a bundle manifest.yml.
12
14
  #
@@ -41,6 +43,9 @@ module Samagotchi
41
43
  # cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
42
44
  # why: GitHub PR # optional
43
45
  # every_seconds: 300 # optional
46
+ # block_renderers: # optional — a fence's language → the scripts/
47
+ # - lang: mermaid # files chi web's page loads to draw it
48
+ # load: [lib.js, draw.js] # (BlockRenderers); each one a scripts: key
44
49
  # session_settings: [cap] # optional — the settings a session may set
45
50
  # # (chi --set cost-cap.cap=3), read by the
46
51
  # # plugin from ctx.settings at call time;
@@ -71,7 +76,7 @@ module Samagotchi
71
76
  # the description a mapping gave, for the files that have one; hooks:
72
77
  # name → BundleHook.
73
78
  attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
74
- :scripts, :context_providers, :session_settings, :session_setting_types, :settings, :setting_types
79
+ :scripts, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings, :setting_types
75
80
 
76
81
  def initialize(path:)
77
82
  @path = path
@@ -94,6 +99,7 @@ module Samagotchi
94
99
  @needs = self.class.parse_needs(raw["needs"])
95
100
  @scripts = parse_scripts(raw["scripts"])
96
101
  @context_providers = parse_context_providers(raw["context_providers"])
102
+ @block_renderers = parse_block_renderers(raw["block_renderers"])
97
103
  @includes = parse_includes(raw["includes"])
98
104
  @session_settings = self.class.parse_session_settings(raw["session_settings"])
99
105
  @session_setting_types = self.class.parse_session_setting_types(raw["session_settings"])
@@ -163,7 +169,7 @@ module Samagotchi
163
169
  # Writes a fresh manifest.yml from computed checksums at `dir`.
164
170
  # +file_descriptions+ (name → text) writes those files as mappings.
165
171
  def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
166
- plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
172
+ plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], block_renderers: [], file_descriptions: {},
167
173
  session_settings: [], session_setting_types: {}, settings: nil, setting_types: {})
168
174
  FileUtils.mkdir_p(dir)
169
175
  manifest = {
@@ -187,6 +193,7 @@ module Samagotchi
187
193
  manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
188
194
  manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
189
195
  manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
196
+ manifest["block_renderers"] = block_renderers.map(&:to_h) unless block_renderers.empty?
190
197
  manifest["session_settings"] = session_settings_entry(session_settings, session_setting_types) unless Array(session_settings).empty?
191
198
  manifest["settings"] = session_settings_entry(settings, setting_types) unless settings.nil?
192
199
  File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
@@ -362,6 +369,20 @@ module Samagotchi
362
369
  raise ValidationError, e.message
363
370
  end
364
371
 
372
+ # @return [Array<BlockRenderers::Renderer>] each load is a scripts: key
373
+ def parse_block_renderers(raw)
374
+ return [] if raw.nil?
375
+
376
+ renderers = BlockRenderers.parse_list(raw)
377
+ renderers.each do |renderer|
378
+ missing = renderer.load - @scripts.keys
379
+ raise ValidationError, "block_renderers: #{renderer.lang} loads #{missing.join(", ")}, not under scripts:" unless missing.empty?
380
+ end
381
+ renderers
382
+ rescue BlockRenderers::Invalid => e
383
+ raise ValidationError, e.message
384
+ end
385
+
365
386
  # includes: → the names in order, duplicates dropped; [] when absent.
366
387
  # A meta ships nothing of its own, so files, hooks or a plugin next
367
388
  # to it is a ValidationError.