stream_weaver 0.3.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 +7 -0
- data/.beads/.gitignore +77 -0
- data/.beads/README.md +81 -0
- data/.beads/config.yaml +54 -0
- data/.beads/metadata.json +7 -0
- data/.rspec +3 -0
- data/AGENTS.md +91 -0
- data/CHANGELOG.md +294 -0
- data/CLAUDE.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +565 -0
- data/Rakefile +13 -0
- data/assets/streamweaver-hero.jpg +0 -0
- data/bench/baselines/ledger.rb +58 -0
- data/bench/baselines/warroom.rb +51 -0
- data/bench/fixtures/ledger.rb +76 -0
- data/bench/fixtures/warroom.rb +60 -0
- data/bench/results/6daf8b8.md +102 -0
- data/bench/results/c8d749b.md +102 -0
- data/bench/results/eb06ab9.md +102 -0
- data/bench/run.rb +151 -0
- data/bench/support.rb +84 -0
- data/copy_this/dto_run_sample_run_7631.html +220 -0
- data/docs/SERVICE_MODE.md +302 -0
- data/docs/architecture/how_streamweaver_works.md +772 -0
- data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
- data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
- data/docs/canvas-ipc-session-summary.md +127 -0
- data/docs/canvas-panel-workflow.md +172 -0
- data/docs/canvas-read.md +147 -0
- data/docs/canvas-roadmap.md +275 -0
- data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
- data/docs/claude-code-companion-skill-spec.md +567 -0
- data/docs/components_reference.md +1223 -0
- data/docs/crud-patterns.md +265 -0
- data/docs/endpoints.md +122 -0
- data/docs/for_llms.md +1 -0
- data/docs/form-patterns.md +165 -0
- data/docs/frontend-only.md +169 -0
- data/docs/html-artifact-audit.md +238 -0
- data/docs/ideas/2025-01-01-as-a-service.md +203 -0
- data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
- data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
- data/docs/ideas/dhh-review-future-refactors.md +142 -0
- data/docs/opal-jamstack.md +47 -0
- data/docs/opal-npm-direction.md +184 -0
- data/docs/opal-spike-findings.md +194 -0
- data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
- data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
- data/docs/plans/2026-01-02-charts-design.md +288 -0
- data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
- data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
- data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
- data/docs/plans/canvas-claude-project-design.md +238 -0
- data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
- data/docs/plans/org-doc-preview-surfaces.md +293 -0
- data/docs/plans/shadcn-polish-plan.md +417 -0
- data/docs/porting-artifacts.md +252 -0
- data/docs/reference/agent-skills-comparison.md +369 -0
- data/docs/reference/travel-state-prd.artifact.html +780 -0
- data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
- data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
- data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
- data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
- data/docs/research/frontend-only-matrix.md +278 -0
- data/docs/research/streamweaver-way-spike-findings.md +529 -0
- data/docs/resource-dsl.md +342 -0
- data/docs/routing.md +302 -0
- data/docs/ruby-ui-comparison.md +319 -0
- data/docs/shared-dsl-fragments.md +356 -0
- data/docs/streamweaver-for-ai-agents.md +401 -0
- data/docs/streamweaver-frontend-vision.md +123 -0
- data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
- data/docs/streamweaver_canvas/save_example.rb +9 -0
- data/docs/templates.md +213 -0
- data/docs/testing.md +155 -0
- data/docs/theming-hooks.md +281 -0
- data/docs/tutorials/the-streamweaver-way.md +882 -0
- data/docs/university/capability-inventory.md +137 -0
- data/docs/university/dependency-survey.md +114 -0
- data/docs/university/design-spec.md +217 -0
- data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
- data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
- data/docs/university/mockups/course-list-dark.png +0 -0
- data/docs/university/mockups/course-list-light.png +0 -0
- data/docs/university/mockups/course_canvas_mockup.rb +741 -0
- data/docs/university/mockups/step-screen-dark.png +0 -0
- data/docs/university/mockups/step-screen-light.png +0 -0
- data/docs/university/roadmap.md +72 -0
- data/docs/university/send-to-coworker.md +77 -0
- data/docs/university/worker-session-mining.md +109 -0
- data/docs/visual/browser-rendering-spike.md +173 -0
- data/docs/visual/builder-visual-plan-analysis.md +378 -0
- data/docs/visual/sw-plan-format-exploration.md +511 -0
- data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
- data/docs/visual-skills/PROGRESS.md +60 -0
- data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
- data/docs/visual-skills/analysis/components.md +317 -0
- data/docs/visual-skills/analysis/overlap.md +360 -0
- data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
- data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
- data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
- data/docs/visual-skills/analysis/visual-explainer.md +964 -0
- data/docs/visual-skills/blog/blog-series-outline.md +39 -0
- data/docs/visual-skills/blog/token-efficiency.md +73 -0
- data/docs/visual-skills/design/architecture.md +1725 -0
- data/docs/visual-skills/design/codex-review.md +340 -0
- data/docs/visual-skills/design/dhh-review.md +209 -0
- data/docs/visual-skills/design/evolution.md +100 -0
- data/docs/visual-skills/design/gemini-review.md +55 -0
- data/docs/visual-skills/design/review-synthesis.md +149 -0
- data/docs/visual-skills/implementation/STATE.md +22 -0
- data/docs/visual-skills/implementation/plan.md +347 -0
- data/docs/visual-skills/implementation/spike-findings.md +237 -0
- data/docs/visual-skills/implementation/tasks.md +702 -0
- data/docs/visual-skills/lessons-learned/process.md +145 -0
- data/examples/README.md +96 -0
- data/examples/advanced/all_components.rb +177 -0
- data/examples/advanced/examples_browser.rb +394 -0
- data/examples/advanced/teachables_browser.rb +261 -0
- data/examples/advanced/theme_tweaker.rb +366 -0
- data/examples/advanced/tutorial.rb +1528 -0
- data/examples/agentic/agentic_form.rb +30 -0
- data/examples/agentic/agentic_form_autoclose.rb +30 -0
- data/examples/agentic/cultivation_tracker.rb +69 -0
- data/examples/basic/hello_world.rb +22 -0
- data/examples/basic/opal_tabs_table.rb +44 -0
- data/examples/basic/todo_list.rb +39 -0
- data/examples/button_loading_test.rb +36 -0
- data/examples/canvas/mermaid_canvas_demo.sh +151 -0
- data/examples/charts/bar_chart_demo.rb +57 -0
- data/examples/charts/line_chart_demo.rb +73 -0
- data/examples/charts/pie_area_demo.rb +87 -0
- data/examples/charts/stacked_bar_chart_demo.rb +69 -0
- data/examples/claude_code/README.md +85 -0
- data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
- data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
- data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
- data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
- data/examples/claude_code/codebreaker/README.md +213 -0
- data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
- data/examples/claude_code/tutorial/README.md +102 -0
- data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
- data/examples/claude_code/verification_flow/README.md +126 -0
- data/examples/components/annotated_code_demo.rb +54 -0
- data/examples/components/callout_demo.rb +33 -0
- data/examples/components/checkbox_group_demo.rb +46 -0
- data/examples/components/design_review.css +468 -0
- data/examples/components/design_review_demo.rb +44 -0
- data/examples/components/design_review_dsl.rb +300 -0
- data/examples/components/diff_block_demo.rb +112 -0
- data/examples/components/events_demo.rb +184 -0
- data/examples/components/form_demo.rb +62 -0
- data/examples/components/lesson_demo.rb +53 -0
- data/examples/components/markdown_demo.rb +110 -0
- data/examples/components/mermaid_demo.rb +166 -0
- data/examples/components/pareto_set.rb +64 -0
- data/examples/components/prd_demo.rb +21 -0
- data/examples/components/prd_dsl.rb +385 -0
- data/examples/components/quiz_demo.rb +47 -0
- data/examples/components/run_viewer_demo.rb +100 -0
- data/examples/components/score_and_collapsible_demo.rb +82 -0
- data/examples/components/select_stale_value_smoke_test.rb +39 -0
- data/examples/components/table_demo.rb +247 -0
- data/examples/components/timer_with_state_demo.rb +58 -0
- data/examples/components/todo_due_dates.rb +63 -0
- data/examples/components/uat_gaps_demo.rb +112 -0
- data/examples/dashboard/feed_simulator.rb +60 -0
- data/examples/dashboard/live_dashboard.rb +80 -0
- data/examples/dashboard_components.rb +108 -0
- data/examples/deferred_fragments_demo.rb +62 -0
- data/examples/generate_more_spike/README.md +63 -0
- data/examples/generate_more_spike/app.rb +441 -0
- data/examples/git_health.sh +401 -0
- data/examples/layout/layout_components_demo.rb +174 -0
- data/examples/layout/modal_demo.rb +215 -0
- data/examples/layout/navigation_demo.rb +227 -0
- data/examples/layout/route_tabs_demo.rb +52 -0
- data/examples/layout/routing_demo.rb +52 -0
- data/examples/layout/scroll_box_demo.rb +154 -0
- data/examples/lazy_fragments_demo.rb +151 -0
- data/examples/my_todos/README.md +29 -0
- data/examples/my_todos/my_todos.rb +312 -0
- data/examples/my_todos/store.rb +86 -0
- data/examples/opal/reactive_demo.rb +62 -0
- data/examples/opal/scenarios/s1_counter.rb +15 -0
- data/examples/opal/scenarios/s2_search_filter.rb +17 -0
- data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
- data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
- data/examples/opal/scenarios/s5_watch.rb +39 -0
- data/examples/opal/scenarios/s6_on_start.rb +20 -0
- data/examples/opal/scenarios/s7_wizard.rb +33 -0
- data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
- data/examples/opal/scenarios/s9_dashboard.rb +27 -0
- data/examples/operations_dashboard_demo.rb +167 -0
- data/examples/panel_demo.sh +447 -0
- data/examples/parity/assets/tyrion_slice.css +86 -0
- data/examples/parity/rivet_people_slice.rb +182 -0
- data/examples/parity/tyrion_components.css +777 -0
- data/examples/parity/tyrion_warroom_components.rb +414 -0
- data/examples/parity/tyrion_warroom_slice.rb +549 -0
- data/examples/puma_dev/README.md +124 -0
- data/examples/puma_dev/config.ru +34 -0
- data/examples/puma_dev/standalone_app.rb +34 -0
- data/examples/scaffolding/blog.rb +49 -0
- data/examples/scaffolding/utf_lite.rb +105 -0
- data/examples/styling/feedback_demo.rb +229 -0
- data/examples/styling/style_showcase.rb +172 -0
- data/examples/styling/theme_demo.rb +508 -0
- data/examples/timer_health_checker.rb +111 -0
- data/examples/timer_showcase.rb +131 -0
- data/examples/tutorials/render_markdown.rb +112 -0
- data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
- data/examples/tutorials/tutorial_content.rb +1082 -0
- data/examples/visual_skills/design_deck_demo.rb +314 -0
- data/examples/visual_skills/explainer_demo.rb +499 -0
- data/exe/streamweaver +9 -0
- data/gsd/ROADMAP-1.0.md +167 -0
- data/gsd/STATE.md +13 -0
- data/gsd/research/market-positioning-research.md +139 -0
- data/gsd/research/production-patterns-research.md +270 -0
- data/gsd/research/repo-audit-1.0.md +274 -0
- data/lib/stream_weaver/action_token.rb +58 -0
- data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
- data/lib/stream_weaver/adapter/base.rb +591 -0
- data/lib/stream_weaver/adapter/opal.rb +334 -0
- data/lib/stream_weaver/adapter/static.rb +1118 -0
- data/lib/stream_weaver/admin.rb +176 -0
- data/lib/stream_weaver/app.rb +1821 -0
- data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
- data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
- data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
- data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
- data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
- data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
- data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
- data/lib/stream_weaver/canvas/bridge.rb +239 -0
- data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
- data/lib/stream_weaver/canvas/client.rb +298 -0
- data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
- data/lib/stream_weaver/canvas/doc_store.rb +252 -0
- data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
- data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
- data/lib/stream_weaver/canvas/gist_store.rb +135 -0
- data/lib/stream_weaver/canvas/helpers.rb +109 -0
- data/lib/stream_weaver/canvas/history.rb +90 -0
- data/lib/stream_weaver/canvas/protocol.rb +86 -0
- data/lib/stream_weaver/canvas/reader.rb +853 -0
- data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
- data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
- data/lib/stream_weaver/canvas/session.rb +132 -0
- data/lib/stream_weaver/cli.rb +3235 -0
- data/lib/stream_weaver/component_assets.rb +70 -0
- data/lib/stream_weaver/component_registry.rb +67 -0
- data/lib/stream_weaver/component_renderer.rb +48 -0
- data/lib/stream_weaver/components/annotated_code.rb +53 -0
- data/lib/stream_weaver/components/api_endpoint.rb +42 -0
- data/lib/stream_weaver/components/callout.rb +59 -0
- data/lib/stream_weaver/components/chart.rb +84 -0
- data/lib/stream_weaver/components/code_block.rb +77 -0
- data/lib/stream_weaver/components/comparison.rb +39 -0
- data/lib/stream_weaver/components/decision.rb +38 -0
- data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
- data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
- data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
- data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
- data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
- data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
- data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
- data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
- data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
- data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
- data/lib/stream_weaver/components/diff_block.rb +125 -0
- data/lib/stream_weaver/components/doc_header.rb +57 -0
- data/lib/stream_weaver/components/image_block.rb +70 -0
- data/lib/stream_weaver/components/implementation_map.rb +33 -0
- data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
- data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
- data/lib/stream_weaver/components/mermaid.rb +79 -0
- data/lib/stream_weaver/components/pipeline.rb +63 -0
- data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
- data/lib/stream_weaver/components/slide_container.rb +141 -0
- data/lib/stream_weaver/components/timeline_event.rb +58 -0
- data/lib/stream_weaver/components/wireframe.rb +29 -0
- data/lib/stream_weaver/components/wireframe_block.rb +34 -0
- data/lib/stream_weaver/components.rb +2711 -0
- data/lib/stream_weaver/css.rb +230 -0
- data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
- data/lib/stream_weaver/display_dsl.rb +1004 -0
- data/lib/stream_weaver/export/html_exporter.rb +478 -0
- data/lib/stream_weaver/feed.rb +34 -0
- data/lib/stream_weaver/feed_builder.rb +31 -0
- data/lib/stream_weaver/fonts.rb +33 -0
- data/lib/stream_weaver/interaction_runner.rb +487 -0
- data/lib/stream_weaver/iterm.rb +460 -0
- data/lib/stream_weaver/layout_registry.rb +92 -0
- data/lib/stream_weaver/opal/bridge.rb +52 -0
- data/lib/stream_weaver/opal/builder.rb +193 -0
- data/lib/stream_weaver/opal/env.rb +29 -0
- data/lib/stream_weaver/opal/reactive_state.rb +75 -0
- data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
- data/lib/stream_weaver/opal/renderer.rb +77 -0
- data/lib/stream_weaver/opal/runtime.rb +250 -0
- data/lib/stream_weaver/opal/shell.rb +145 -0
- data/lib/stream_weaver/opal/string_bridge.rb +50 -0
- data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
- data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
- data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
- data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
- data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
- data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
- data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
- data/lib/stream_weaver/opal_entry.rb +136 -0
- data/lib/stream_weaver/org/inline.rb +95 -0
- data/lib/stream_weaver/org/reader.rb +561 -0
- data/lib/stream_weaver/org/recording_context.rb +86 -0
- data/lib/stream_weaver/org/source_splitter.rb +63 -0
- data/lib/stream_weaver/org/writer.rb +300 -0
- data/lib/stream_weaver/page_shell.rb +530 -0
- data/lib/stream_weaver/portfile.rb +79 -0
- data/lib/stream_weaver/pushable.rb +42 -0
- data/lib/stream_weaver/resource/default_views.rb +111 -0
- data/lib/stream_weaver/resource/field_input.rb +25 -0
- data/lib/stream_weaver/resource/state_keys.rb +16 -0
- data/lib/stream_weaver/resource/store.rb +18 -0
- data/lib/stream_weaver/resource.rb +104 -0
- data/lib/stream_weaver/server.rb +1471 -0
- data/lib/stream_weaver/service.rb +1240 -0
- data/lib/stream_weaver/service_client.rb +104 -0
- data/lib/stream_weaver/session_store.rb +186 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
- data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
- data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
- data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
- data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
- data/lib/stream_weaver/streamer.rb +63 -0
- data/lib/stream_weaver/templates/choices.rb +149 -0
- data/lib/stream_weaver/templates/code.rb +157 -0
- data/lib/stream_weaver/templates/confirm.rb +110 -0
- data/lib/stream_weaver/templates/diff.rb +195 -0
- data/lib/stream_weaver/templates/info.rb +142 -0
- data/lib/stream_weaver/templates/table.rb +169 -0
- data/lib/stream_weaver/templates/wizard.rb +271 -0
- data/lib/stream_weaver/theme/auto_mode.rb +116 -0
- data/lib/stream_weaver/theme/presets.rb +555 -0
- data/lib/stream_weaver/theme.rb +639 -0
- data/lib/stream_weaver/university/canvas.rb +924 -0
- data/lib/stream_weaver/university/course.rb +699 -0
- data/lib/stream_weaver/university/demos/counter.rb +33 -0
- data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
- data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
- data/lib/stream_weaver/university/demos.rb +50 -0
- data/lib/stream_weaver/university/listener.rb +467 -0
- data/lib/stream_weaver/university/progress.rb +201 -0
- data/lib/stream_weaver/university/runner.rb +138 -0
- data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
- data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
- data/lib/stream_weaver/utils.rb +31 -0
- data/lib/stream_weaver/version.rb +5 -0
- data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
- data/lib/stream_weaver/views.rb +3940 -0
- data/lib/stream_weaver.rb +122 -0
- data/llms.txt +1269 -0
- data/sig/stream_weaver.rbs +4 -0
- metadata +598 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
# Mutually required with doc_roots (which needs DOCS_SUBPATH/DEFAULT_ROOT for
|
|
6
|
+
# its scan and its global group). Safe in both load orders: Ruby marks a file
|
|
7
|
+
# as loaded before evaluating it, so the second require is a no-op, and
|
|
8
|
+
# neither file touches the other's constants at load time -- only inside
|
|
9
|
+
# method bodies.
|
|
10
|
+
require 'stream_weaver/canvas/doc_roots'
|
|
11
|
+
|
|
12
|
+
module StreamWeaver
|
|
13
|
+
module Canvas
|
|
14
|
+
# Tier 2 (persistent, in-repo) storage for canvas docs the user has
|
|
15
|
+
# explicitly chosen to keep. Sibling of Canvas::History (Tier 1, ephemeral).
|
|
16
|
+
#
|
|
17
|
+
# Resolved path priority:
|
|
18
|
+
# 1. ENV['STREAMWEAVER_DOC_ROOT'] (test/override hook)
|
|
19
|
+
# 2. <git_root>/docs/streamweaver_canvas (when invoked inside a repo)
|
|
20
|
+
# 3. ~/.streamweaver/canvas (no-repo fallback)
|
|
21
|
+
#
|
|
22
|
+
# Git-root detection walks the filesystem looking for a .git entry on the
|
|
23
|
+
# current working directory or any ancestor. We deliberately do NOT shell
|
|
24
|
+
# out to `git`: filesystem walking is faster, has no PATH/install
|
|
25
|
+
# dependency, and is easy to stub in specs. A .git that is either a
|
|
26
|
+
# directory (normal repo) or a file (worktree/submodule pointer) counts
|
|
27
|
+
# as a hit -- File.exist? covers both.
|
|
28
|
+
#
|
|
29
|
+
# Doc names are validated against an allowlist (alnum + . _ -, must start
|
|
30
|
+
# with alnum) and an explicit '..'-substring + null-byte check. Bad names
|
|
31
|
+
# raise ArgumentError -- raising rather than sanitizing keeps the contract
|
|
32
|
+
# explicit and avoids silently writing to an unexpected file. The
|
|
33
|
+
# extension is forced and idempotent: .rb and .org are both valid
|
|
34
|
+
# (save("foo") and save("foo.rb") both write to <path>/foo.rb; likewise
|
|
35
|
+
# for .org), defaulting to .rb when neither is present. Extra dots
|
|
36
|
+
# within the name are allowed (auth-flow.v2 -> auth-flow.v2.rb) since
|
|
37
|
+
# the allowlist already permits '.' and descriptive multi-segment names
|
|
38
|
+
# are useful.
|
|
39
|
+
#
|
|
40
|
+
# No cleanup method: Tier 2 is permanent. Git is the cleanup mechanism.
|
|
41
|
+
module DocStore
|
|
42
|
+
DEFAULT_ROOT = File.expand_path('~/.streamweaver/canvas')
|
|
43
|
+
DOCS_SUBPATH = File.join('docs', 'streamweaver_canvas')
|
|
44
|
+
VALID_NAME = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
|
|
45
|
+
|
|
46
|
+
# Marks a file as a StreamWeaver doc body.
|
|
47
|
+
#
|
|
48
|
+
# A saved doc is a bare DSL body -- no require, no app wrapper -- so
|
|
49
|
+
# nothing about the file announces what it is. Tooling that finds one out
|
|
50
|
+
# of context (a renderer looking at a GitHub blob, an editor plugin, a
|
|
51
|
+
# human) has no reliable way to tell it apart from ordinary Ruby.
|
|
52
|
+
# Guessing from content does not work either: substantial docs use a dozen
|
|
53
|
+
# distinct DSL calls, but a thin one may be almost entirely `md`.
|
|
54
|
+
#
|
|
55
|
+
# A comment costs nothing at eval time and travels with the file wherever
|
|
56
|
+
# it goes, independent of path or extension.
|
|
57
|
+
STAMP = '# streamweaver-doc: v1'
|
|
58
|
+
|
|
59
|
+
# Recognizes the stamp anywhere in a leading comment block, so it still
|
|
60
|
+
# matches if a magic comment (frozen_string_literal) or a title comment
|
|
61
|
+
# sits above it. The version is captured but not pinned -- a v2 doc should
|
|
62
|
+
# still be recognizable as a doc.
|
|
63
|
+
STAMP_RE = /^#\s*streamweaver-doc:\s*v(\d+)\s*$/
|
|
64
|
+
|
|
65
|
+
# How far into a file to look. Deep enough for a comment header, shallow
|
|
66
|
+
# enough that a stray match in prose does not count.
|
|
67
|
+
STAMP_SCAN_LINES = 10
|
|
68
|
+
|
|
69
|
+
module_function
|
|
70
|
+
|
|
71
|
+
# Resolves the docs directory. Read fresh on every call so an ENV
|
|
72
|
+
# override flips behavior mid-process (specs rely on this).
|
|
73
|
+
def path
|
|
74
|
+
env = ENV['STREAMWEAVER_DOC_ROOT']
|
|
75
|
+
return env if env && !env.empty?
|
|
76
|
+
|
|
77
|
+
if (root = git_root)
|
|
78
|
+
File.join(root, DOCS_SUBPATH)
|
|
79
|
+
else
|
|
80
|
+
DEFAULT_ROOT
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Walks up from `start` looking for a .git entry. Returns the absolute
|
|
85
|
+
# path of the directory that contains it, or nil at the filesystem
|
|
86
|
+
# root. Treats .git as a hit whether it is a directory or a file
|
|
87
|
+
# (worktree/submodule).
|
|
88
|
+
def git_root(start = Dir.pwd)
|
|
89
|
+
dir = File.expand_path(start)
|
|
90
|
+
loop do
|
|
91
|
+
return dir if File.exist?(File.join(dir, '.git'))
|
|
92
|
+
|
|
93
|
+
parent = File.dirname(dir)
|
|
94
|
+
return nil if parent == dir
|
|
95
|
+
|
|
96
|
+
dir = parent
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Writes `dsl` to <path>/<normalized_name>.rb and returns the absolute
|
|
101
|
+
# path. Creates the docs directory if missing. Overwrites any existing
|
|
102
|
+
# file with the same name (Tier 2 docs are user-managed; collisions
|
|
103
|
+
# mean the user is intentionally updating).
|
|
104
|
+
#
|
|
105
|
+
# scope/source_dir (stream_weaver-j3b3): a caller with an explicit
|
|
106
|
+
# destination in mind (the Save-as-doc toggle) passes both. `scope:
|
|
107
|
+
# :global` always writes to DEFAULT_ROOT. `scope: :repo` (the default)
|
|
108
|
+
# writes under `source_dir` when given; when `source_dir` is nil (no
|
|
109
|
+
# caller preference, e.g. a caller with no repo context to offer) it
|
|
110
|
+
# falls back to the existing auto-detected `path` -- unchanged
|
|
111
|
+
# behavior for every pre-existing caller.
|
|
112
|
+
#
|
|
113
|
+
# Only .rb output gets the `# streamweaver-doc: v1` stamp -- .org output
|
|
114
|
+
# already self-identifies via its own `#+STREAMWEAVER_DSL: 1` header
|
|
115
|
+
# keyword (org-doc-format-design.md), which must be the file's first
|
|
116
|
+
# line unconditionally. Prepending the .rb-style stamp in front of it
|
|
117
|
+
# would violate that and, being a bare `#` line rather than a `#+`
|
|
118
|
+
# keyword, wouldn't even be recognized by org-ruby.
|
|
119
|
+
#
|
|
120
|
+
# The write is atomic: content goes to a temp file in the SAME directory
|
|
121
|
+
# (so the rename stays on one filesystem, where POSIX guarantees it is
|
|
122
|
+
# atomic) and is then renamed over the target. A plain File.write
|
|
123
|
+
# truncates first, so a concurrent reader -- canvas-read's docs scan on
|
|
124
|
+
# every render, or another process's GET -- can observe an empty or
|
|
125
|
+
# half-written file. The temp name is dotted and .tmp-suffixed so it
|
|
126
|
+
# matches neither the *.rb nor the *.org globs even in the instant it
|
|
127
|
+
# exists, and it is removed if the write or rename fails so a failure
|
|
128
|
+
# never litters the docs directory.
|
|
129
|
+
def save(name, dsl, scope: :repo, source_dir: nil)
|
|
130
|
+
filename = normalize_name(name)
|
|
131
|
+
dir = target_dir(scope, source_dir)
|
|
132
|
+
FileUtils.mkdir_p(dir)
|
|
133
|
+
|
|
134
|
+
full = File.join(dir, filename)
|
|
135
|
+
content = filename.end_with?('.org') ? dsl : stamp(dsl)
|
|
136
|
+
tmp = File.join(dir, ".#{filename}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp")
|
|
137
|
+
begin
|
|
138
|
+
File.write(tmp, content)
|
|
139
|
+
File.rename(tmp, full)
|
|
140
|
+
rescue StandardError
|
|
141
|
+
FileUtils.rm_f(tmp)
|
|
142
|
+
raise
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Half of canvas-read's cross-repo discovery (stream_weaver-iugu):
|
|
146
|
+
# saving into a repo is what makes that repo's docs findable from a
|
|
147
|
+
# canvas-read launched anywhere else. Deliberately after the rename,
|
|
148
|
+
# so a failed write never registers a root that has no docs in it.
|
|
149
|
+
# DocRoots.record swallows its own filesystem errors -- a registry
|
|
150
|
+
# that can't be written must never fail the save it followed.
|
|
151
|
+
DocRoots.record(dir)
|
|
152
|
+
|
|
153
|
+
full
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Prepends `use_theme`/`use_layout` declarations so a saved doc renders
|
|
157
|
+
# with the theme/layout of the canvas session it came from
|
|
158
|
+
# (stream_weaver-csf). Saved docs are re-rendered later by canvas-read,
|
|
159
|
+
# which has no session to inherit from -- only the DSL text -- so the
|
|
160
|
+
# metadata has to live in the text itself.
|
|
161
|
+
#
|
|
162
|
+
# Shared by BOTH save routes (BridgeServer's /canvas/:name/save-doc and
|
|
163
|
+
# Reader's /save-doc) deliberately: duplicating the prepend logic would
|
|
164
|
+
# let the two Save-as-doc buttons silently write different files.
|
|
165
|
+
#
|
|
166
|
+
# Idempotent: a DSL that already declares either directive keeps its own,
|
|
167
|
+
# so re-saving never stacks duplicates. nil theme/layout are skipped --
|
|
168
|
+
# that's the Reader's promote-from-history case, where the snapshot was
|
|
169
|
+
# written by `canvas-push` (which never sees the bridge session's theme),
|
|
170
|
+
# so those docs keep rendering with canvas-read's default.
|
|
171
|
+
def dsl_with_metadata(dsl, theme: nil, layout: nil)
|
|
172
|
+
lines = []
|
|
173
|
+
lines << "use_theme :#{theme}" if theme && !dsl.match?(/^[ \t]*use_theme\b/)
|
|
174
|
+
lines << "use_layout :#{layout}" if layout && !dsl.match?(/^[ \t]*use_layout\b/)
|
|
175
|
+
return dsl if lines.empty?
|
|
176
|
+
|
|
177
|
+
(lines + [dsl]).join("\n")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# True when `source` already declares itself a StreamWeaver doc.
|
|
181
|
+
#
|
|
182
|
+
# Only the first STAMP_SCAN_LINES are considered, so a doc that happens to
|
|
183
|
+
# quote the stamp inside prose or a code_block further down is not
|
|
184
|
+
# mistaken for a stamped file.
|
|
185
|
+
def stamped?(source)
|
|
186
|
+
return false unless source.is_a?(String)
|
|
187
|
+
|
|
188
|
+
source.each_line.first(STAMP_SCAN_LINES).any? { |line| line.match?(STAMP_RE) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Returns `dsl` with the stamp on its first line, or unchanged if it is
|
|
192
|
+
# already stamped.
|
|
193
|
+
#
|
|
194
|
+
# Idempotent on purpose: saving over an existing doc is the normal way to
|
|
195
|
+
# update one, and the stamp must not accumulate. Prepending is safe even
|
|
196
|
+
# when the body opens with `# frozen_string_literal: true` -- Ruby honors
|
|
197
|
+
# a magic comment anywhere in the leading comment block, not only on line
|
|
198
|
+
# one.
|
|
199
|
+
def stamp(dsl)
|
|
200
|
+
text = dsl.to_s
|
|
201
|
+
return text if stamped?(text)
|
|
202
|
+
# Defense in depth, not currently reachable: `save`'s own
|
|
203
|
+
# filename.end_with?('.org') check is what actually protects org
|
|
204
|
+
# content today (`stamp` has no other caller). Kept here too so a
|
|
205
|
+
# future caller of `stamp` directly can't reintroduce the exact bug
|
|
206
|
+
# c1df5f6 fixed -- the .rb-style stamp corrupting .org's hard
|
|
207
|
+
# requirement that '#+STREAMWEAVER_DSL: 1' be the literal first line.
|
|
208
|
+
return text if text.start_with?('#+')
|
|
209
|
+
return "#{STAMP}\n" if text.empty?
|
|
210
|
+
|
|
211
|
+
"#{STAMP}\n#{text}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Strips a single trailing .rb or .org (case-sensitive), validates the
|
|
215
|
+
# resulting basename against VALID_NAME with explicit '..' and
|
|
216
|
+
# null-byte rejection, then re-adds whichever extension was detected
|
|
217
|
+
# (defaulting to .rb when neither is present). Bare ".rb"/".org"
|
|
218
|
+
# normalize to an empty basename and are rejected.
|
|
219
|
+
def normalize_name(name)
|
|
220
|
+
raise ArgumentError, "invalid doc name: #{name.inspect}" unless name.is_a?(String)
|
|
221
|
+
|
|
222
|
+
ext, stripped = if name.end_with?('.rb')
|
|
223
|
+
['.rb', name[0...-3]]
|
|
224
|
+
elsif name.end_with?('.org')
|
|
225
|
+
['.org', name[0...-4]]
|
|
226
|
+
else
|
|
227
|
+
['.rb', name]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
unless !stripped.empty? &&
|
|
231
|
+
!stripped.include?('..') &&
|
|
232
|
+
!stripped.include?("\0") &&
|
|
233
|
+
stripped.match?(VALID_NAME)
|
|
234
|
+
raise ArgumentError, "invalid doc name: #{name.inspect}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
"#{stripped}#{ext}"
|
|
238
|
+
end
|
|
239
|
+
private_class_method :normalize_name
|
|
240
|
+
|
|
241
|
+
# Resolves the destination directory for `save`'s scope/source_dir
|
|
242
|
+
# arguments. See `save`'s comment for the semantics.
|
|
243
|
+
def target_dir(scope, source_dir)
|
|
244
|
+
return DEFAULT_ROOT if scope == :global
|
|
245
|
+
return File.join(source_dir, DOCS_SUBPATH) if source_dir
|
|
246
|
+
|
|
247
|
+
path
|
|
248
|
+
end
|
|
249
|
+
private_class_method :target_dir
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
require_relative 'doc_store'
|
|
7
|
+
require_relative '../org/writer'
|
|
8
|
+
|
|
9
|
+
module StreamWeaver
|
|
10
|
+
module Canvas
|
|
11
|
+
# Publishes a canvas doc to a secret GitHub gist via the `gh` CLI.
|
|
12
|
+
#
|
|
13
|
+
# This is the only place in StreamWeaver that shells out to `gh` and the
|
|
14
|
+
# only place that writes to an external, user-visible URL -- a coworker
|
|
15
|
+
# opens the link this returns, so a bug here ships wrong content to a
|
|
16
|
+
# real person rather than to a file the author can quietly fix.
|
|
17
|
+
#
|
|
18
|
+
# Both files go up in ONE api call: the rendered `.org` (what GitHub
|
|
19
|
+
# renders as a formatted doc -- the reason for sharing at all) and the
|
|
20
|
+
# re-runnable `.rb` DSL source (the source of truth). One save is
|
|
21
|
+
# therefore exactly one gist revision, which is what makes
|
|
22
|
+
# `<gist_url>/revisions` a meaningful timeline instead of a stutter of
|
|
23
|
+
# half-updates. `gh gist create`/`gh gist edit` were rejected for this:
|
|
24
|
+
# neither puts two files into a single call cleanly across the
|
|
25
|
+
# create-vs-update split, and neither gives direct control over the JSON
|
|
26
|
+
# payload.
|
|
27
|
+
#
|
|
28
|
+
# Visibility: secret, always. `public` is never sent -- not on create
|
|
29
|
+
# (gh's own API default is secret, so omitting the key is the same as
|
|
30
|
+
# asking for secret, with one less thing to get backwards) and never on
|
|
31
|
+
# update, where GitHub silently ignores it because gist visibility is
|
|
32
|
+
# immutable after creation. Sending a key that is either redundant or
|
|
33
|
+
# ignored is a landmine for whoever reads this next.
|
|
34
|
+
module GistPublisher
|
|
35
|
+
# Seconds to wait on one `gh api` round trip. The caller is a Sinatra
|
|
36
|
+
# request thread serving the canvas's Save-as-doc dialog; without this,
|
|
37
|
+
# a stalled TLS handshake against api.github.com hangs that thread with
|
|
38
|
+
# the user staring at a spinner. 20s is generous for a two-file POST and
|
|
39
|
+
# still short enough to fail visibly.
|
|
40
|
+
#
|
|
41
|
+
# Timeout.timeout unblocks the CALLER, not the child: a `gh` that is
|
|
42
|
+
# truly wedged may outlive the request by a few seconds before its own
|
|
43
|
+
# network layer gives up. That is acceptable -- it holds no lock and
|
|
44
|
+
# writes nothing of ours -- and is the reason this is a timeout rather
|
|
45
|
+
# than a process kill.
|
|
46
|
+
TIMEOUT_SECONDS = 20
|
|
47
|
+
|
|
48
|
+
# `existing_id` reaches us from the gist store and is interpolated into
|
|
49
|
+
# the API path (`/gists/<id>`), where a `..` segment would silently
|
|
50
|
+
# retarget the request at a different endpoint. argv is already
|
|
51
|
+
# shell-safe, so this is not about shell injection -- it is about not
|
|
52
|
+
# letting a corrupted store entry aim a PATCH somewhere unintended.
|
|
53
|
+
#
|
|
54
|
+
# Alphanumeric rather than strictly hex: today's gist ids are hex, but
|
|
55
|
+
# the id is GitHub's to define and the only property this actually needs
|
|
56
|
+
# is "contains no path syntax".
|
|
57
|
+
VALID_GIST_ID = /\A[A-Za-z0-9]+\z/
|
|
58
|
+
|
|
59
|
+
# Stderr shapes that mean "your credentials are the problem", as
|
|
60
|
+
# opposed to any other API failure. gh's own wording here is
|
|
61
|
+
# famously unhelpful out of context ("Bad credentials (HTTP 401)"
|
|
62
|
+
# tells a canvas user nothing), so these get rewritten into copy that
|
|
63
|
+
# names the actual fix.
|
|
64
|
+
#
|
|
65
|
+
# Every alternative is a specific known gh/GitHub phrase, never a bare
|
|
66
|
+
# keyword. That is deliberate: rewriting an unrelated failure into
|
|
67
|
+
# "run `gh auth login`" would hand the user a confident wrong diagnosis,
|
|
68
|
+
# which is worse than passing gh's own message through untouched. Two
|
|
69
|
+
# consequences of that rule worth naming: a bare HTTP 403 is NOT matched
|
|
70
|
+
# (rate limiting and org policy return it too), and the word "scope"
|
|
71
|
+
# only counts next to language about it being missing or required.
|
|
72
|
+
AUTH_FAILURE_RE = /
|
|
73
|
+
HTTP\s+401
|
|
74
|
+
| bad\s+credentials
|
|
75
|
+
| gh\s+auth\s+login
|
|
76
|
+
| GH_TOKEN
|
|
77
|
+
| not\s+accessible\s+by\s+personal\s+access\s+token
|
|
78
|
+
| (?:required|missing|insufficient|granted|needs)[^\n]{0,60}\bscopes?\b
|
|
79
|
+
| \bscopes?\b[^\n]{0,60}(?:required|missing|insufficient)
|
|
80
|
+
| SAML
|
|
81
|
+
/xi
|
|
82
|
+
|
|
83
|
+
# A deleted-upstream gist. Only meaningful on an update -- see #publish.
|
|
84
|
+
NOT_FOUND_RE = /HTTP\s+404|\bnot\s+found\b/i
|
|
85
|
+
|
|
86
|
+
AUTH_HELP = 'GitHub CLI could not authenticate. Run `gh auth login` and make sure the ' \
|
|
87
|
+
'token has the `gist` scope.'
|
|
88
|
+
|
|
89
|
+
module_function
|
|
90
|
+
|
|
91
|
+
# Renders `dsl` to both formats and publishes them as one gist.
|
|
92
|
+
#
|
|
93
|
+
# Returns a plain hash rather than raising, because every caller is an
|
|
94
|
+
# HTTP handler that has to turn the outcome into a JSON body either way:
|
|
95
|
+
#
|
|
96
|
+
# success: { ok: true, id:, url:, revisions:, action: "create"|"update",
|
|
97
|
+
# coverage: {...} }
|
|
98
|
+
# failure: { ok: false, error: "<message fit to show a user>" }
|
|
99
|
+
#
|
|
100
|
+
# On the stale-id recovery path the success hash also carries
|
|
101
|
+
# `forget_stale_id: "<old id>"` -- the caller's cue to drop that id from
|
|
102
|
+
# GistStore so the next save updates the new gist instead of chasing the
|
|
103
|
+
# deleted one again.
|
|
104
|
+
#
|
|
105
|
+
# ArgumentError from an invalid doc name is deliberately NOT caught: it
|
|
106
|
+
# is a 422 (the user typed a bad name), not a 502 (the publish failed),
|
|
107
|
+
# and the existing save-doc handlers already map ArgumentError that way.
|
|
108
|
+
# Swallowing it here into `ok: false` would flatten that distinction.
|
|
109
|
+
def publish(name:, dsl:, theme: nil, layout: nil, existing_id: nil)
|
|
110
|
+
base = base_name(name)
|
|
111
|
+
|
|
112
|
+
writer = StreamWeaver::Org::Writer.new(dsl)
|
|
113
|
+
org_text = writer.call
|
|
114
|
+
coverage = writer.coverage
|
|
115
|
+
rb_text = DocStore.dsl_with_metadata(dsl, theme: theme, layout: layout)
|
|
116
|
+
|
|
117
|
+
payload = build_payload(base, org_text, rb_text)
|
|
118
|
+
|
|
119
|
+
if existing_id
|
|
120
|
+
unless existing_id.to_s.match?(VALID_GIST_ID)
|
|
121
|
+
return { ok: false, error: "invalid gist id: #{existing_id.inspect}" }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
out, err, status = gh_api('PATCH', "/gists/#{existing_id}", payload)
|
|
125
|
+
return success(out, 'update', coverage) if status.success?
|
|
126
|
+
|
|
127
|
+
# A 404 here means the gist was deleted on github.com since we last
|
|
128
|
+
# recorded it. Publishing is still the thing the user asked for, so
|
|
129
|
+
# mint a fresh gist rather than failing with a message about an id
|
|
130
|
+
# they never saw and cannot act on. Only ever done for an update: a
|
|
131
|
+
# 404 from a create is a real API problem, and retrying it would
|
|
132
|
+
# just double the failure.
|
|
133
|
+
return gh_error(err, status) unless err.to_s.match?(NOT_FOUND_RE)
|
|
134
|
+
|
|
135
|
+
result = create(payload, coverage)
|
|
136
|
+
return result unless result[:ok]
|
|
137
|
+
|
|
138
|
+
return result.merge(forget_stale_id: existing_id)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
create(payload, coverage)
|
|
142
|
+
rescue Timeout::Error
|
|
143
|
+
{ ok: false, error: "gh timed out after #{TIMEOUT_SECONDS}s talking to GitHub" }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Cheap presence check: is the `gh` binary on PATH at all?
|
|
147
|
+
#
|
|
148
|
+
# Deliberately NOT an auth check. `gh auth status` costs a network round
|
|
149
|
+
# trip, and this is called on every canvas render to decide whether the
|
|
150
|
+
# Gist radio is enabled -- putting a network call there would make the
|
|
151
|
+
# dialog's responsiveness depend on GitHub being up. Auth problems
|
|
152
|
+
# surface at publish time instead, where #publish already rewrites them
|
|
153
|
+
# into actionable copy (see AUTH_HELP).
|
|
154
|
+
#
|
|
155
|
+
# Memoized: gh does not appear or vanish mid-process, and the widget
|
|
156
|
+
# re-renders often enough that respawning a process per render would be
|
|
157
|
+
# visible.
|
|
158
|
+
def gh_available?
|
|
159
|
+
return @gh_available unless @gh_available.nil?
|
|
160
|
+
|
|
161
|
+
# system returns nil (not false) when the binary is missing, and the
|
|
162
|
+
# caller renders this straight into a JSON/HTML attribute -- coerce so
|
|
163
|
+
# the answer is always a real boolean.
|
|
164
|
+
@gh_available = system('gh', '--version', out: File::NULL, err: File::NULL) ? true : false
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def create(payload, coverage)
|
|
168
|
+
out, err, status = gh_api('POST', '/gists', payload)
|
|
169
|
+
return gh_error(err, status) unless status.success?
|
|
170
|
+
|
|
171
|
+
success(out, 'create', coverage)
|
|
172
|
+
end
|
|
173
|
+
private_class_method :create
|
|
174
|
+
|
|
175
|
+
# One `gh api` round trip. Array argv, never a shell string: the doc
|
|
176
|
+
# name and gist id both reach this method from user input, and a shell
|
|
177
|
+
# string is the one way to turn either into command execution.
|
|
178
|
+
# `--input -` puts the JSON on stdin so no part of the payload ever
|
|
179
|
+
# becomes an argument.
|
|
180
|
+
def gh_api(verb, path, payload)
|
|
181
|
+
Timeout.timeout(TIMEOUT_SECONDS) do
|
|
182
|
+
Open3.capture3('gh', 'api', '-X', verb, path, '--input', '-', stdin_data: JSON.generate(payload))
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
private_class_method :gh_api
|
|
186
|
+
|
|
187
|
+
# Note the absence of a `public` key -- see the module comment. Both the
|
|
188
|
+
# create and the update send this exact structure, which is what keeps
|
|
189
|
+
# the two paths from drifting into sending different content.
|
|
190
|
+
def build_payload(base, org_text, rb_text)
|
|
191
|
+
{
|
|
192
|
+
description: description_for(base, org_text),
|
|
193
|
+
files: {
|
|
194
|
+
"#{base}.org" => { content: org_text },
|
|
195
|
+
"#{base}.rb" => { content: rb_text }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
private_class_method :build_payload
|
|
200
|
+
|
|
201
|
+
# The gist description is the line a coworker reads in a list of gists,
|
|
202
|
+
# so the doc's own title beats the filename when there is one.
|
|
203
|
+
# Org::Writer emits `#+TITLE:` only when the DSL declares a doc_header.
|
|
204
|
+
def description_for(base, org_text)
|
|
205
|
+
title = org_text[/^\#\+TITLE:\s*(.+)$/, 1]&.strip
|
|
206
|
+
title && !title.empty? ? title : base
|
|
207
|
+
end
|
|
208
|
+
private_class_method :description_for
|
|
209
|
+
|
|
210
|
+
def success(stdout, action, coverage)
|
|
211
|
+
body = JSON.parse(stdout.to_s)
|
|
212
|
+
# A zero-exit gh whose stdout is valid JSON but not a gist object
|
|
213
|
+
# (`null`, a bare array) would otherwise NoMethodError on body['id']
|
|
214
|
+
# and reach the Sinatra handler as a 500 stack trace. Nothing in gh
|
|
215
|
+
# is known to do this -- the guard exists so an unexpected response
|
|
216
|
+
# degrades to the same readable failure as an unparseable one.
|
|
217
|
+
return { ok: false, error: "unexpected gh response: #{stdout.to_s[0, 200]}" } unless body.is_a?(Hash)
|
|
218
|
+
|
|
219
|
+
{
|
|
220
|
+
ok: true,
|
|
221
|
+
id: body['id'],
|
|
222
|
+
url: body['html_url'],
|
|
223
|
+
# Every gist is a git repo and `history` is its commit list, so its
|
|
224
|
+
# length is the revision count the widget shows next to the
|
|
225
|
+
# /revisions link -- no extra call needed to compute it.
|
|
226
|
+
revisions: Array(body['history']).length,
|
|
227
|
+
action: action,
|
|
228
|
+
coverage: coverage
|
|
229
|
+
}
|
|
230
|
+
rescue JSON::ParserError => e
|
|
231
|
+
{ ok: false, error: "could not parse gh response: #{e.message}" }
|
|
232
|
+
end
|
|
233
|
+
private_class_method :success
|
|
234
|
+
|
|
235
|
+
def gh_error(stderr, status)
|
|
236
|
+
message = stderr.to_s.strip
|
|
237
|
+
return { ok: false, error: "#{AUTH_HELP} (gh said: #{message})" } if message.match?(AUTH_FAILURE_RE)
|
|
238
|
+
return { ok: false, error: "gh exited #{status.exitstatus} with no output" } if message.empty?
|
|
239
|
+
|
|
240
|
+
{ ok: false, error: message }
|
|
241
|
+
end
|
|
242
|
+
private_class_method :gh_error
|
|
243
|
+
|
|
244
|
+
# DocStore.normalize_name is the project's single definition of a valid
|
|
245
|
+
# doc name (allowlist + explicit '..' and null-byte rejection) and it is
|
|
246
|
+
# what the file-writing save routes already validate against. Reusing it
|
|
247
|
+
# keeps a name that is legal for a gist identical to one that is legal
|
|
248
|
+
# for a file, so the two Save-as-doc destinations can never disagree
|
|
249
|
+
# about what the user typed.
|
|
250
|
+
#
|
|
251
|
+
# It is `private_class_method` on DocStore (it has no external caller
|
|
252
|
+
# today), hence the `send`. Making it public is the better long-term
|
|
253
|
+
# fix, but doc_store.rb is out of this story's scope -- flagged for
|
|
254
|
+
# whoever next touches that file.
|
|
255
|
+
#
|
|
256
|
+
# normalize_name returns a name WITH its extension; a gist needs the
|
|
257
|
+
# bare stem, because the publisher appends both extensions itself.
|
|
258
|
+
def base_name(name)
|
|
259
|
+
DocStore.send(:normalize_name, name).sub(/\.(rb|org)\z/, '')
|
|
260
|
+
end
|
|
261
|
+
private_class_method :base_name
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'gist_store'
|
|
4
|
+
require_relative 'gist_publisher'
|
|
5
|
+
|
|
6
|
+
module StreamWeaver
|
|
7
|
+
module Canvas
|
|
8
|
+
# scope == 'gist' branch of a save-doc route (share-to-gist epic),
|
|
9
|
+
# shared between BridgeServer's live-canvas save and Reader's
|
|
10
|
+
# history-snapshot promotion -- the only difference between the two
|
|
11
|
+
# callers is whether there's a session's theme/layout to carry
|
|
12
|
+
# (BridgeServer has one; Reader promotes a snapshot that never saw one,
|
|
13
|
+
# same accepted limitation as this route's org/rb branches -- see
|
|
14
|
+
# reader.rb's own comment on dsl_with_metadata).
|
|
15
|
+
#
|
|
16
|
+
# `include`d into both Sinatra::Base subclasses rather than left as two
|
|
17
|
+
# hand-mirrored copies: it started that way (bridge-canvas-gist-endpoint,
|
|
18
|
+
# then reader-gist-parity), and a second copy is exactly the point past
|
|
19
|
+
# which "keep it duplicated" stops paying for itself -- `halt` below
|
|
20
|
+
# resolves to whichever including Sinatra app calls it.
|
|
21
|
+
module GistSaveHandler
|
|
22
|
+
# base_name mirrors GistPublisher's own (private) base_name derivation
|
|
23
|
+
# for a valid name: DocStore.normalize_name never rewrites characters,
|
|
24
|
+
# it only validates and forces/strips the .rb/.org extension, so
|
|
25
|
+
# stripping the extension here -- same regex the callers' own org
|
|
26
|
+
# branches use -- lands on the identical string GistPublisher records
|
|
27
|
+
# under. A non-String doc_name is passed through as-is (GistStore.lookup
|
|
28
|
+
# coerces via #to_s, so this can only ever miss, not raise) so
|
|
29
|
+
# GistPublisher.publish's own DocStore.normalize_name call is what
|
|
30
|
+
# raises the ArgumentError, exactly like the callers' org/rb branches.
|
|
31
|
+
def handle_gist_save(dsl, doc_name, theme: nil, layout: nil)
|
|
32
|
+
rescue_save_errors do
|
|
33
|
+
base_name = doc_name.is_a?(String) ? doc_name.sub(/\.(rb|org)\z/, '') : doc_name
|
|
34
|
+
existing_id = GistStore.lookup(base_name)&.dig('id')
|
|
35
|
+
|
|
36
|
+
result = GistPublisher.publish(
|
|
37
|
+
name: doc_name,
|
|
38
|
+
dsl: dsl,
|
|
39
|
+
theme: theme,
|
|
40
|
+
layout: layout,
|
|
41
|
+
existing_id: existing_id
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
halt 502, { ok: false, error: result[:error] }.to_json unless result[:ok]
|
|
45
|
+
|
|
46
|
+
response = {
|
|
47
|
+
ok: true,
|
|
48
|
+
gist_url: result[:url],
|
|
49
|
+
gist_id: result[:id],
|
|
50
|
+
revisions: result[:revisions],
|
|
51
|
+
action: result[:action],
|
|
52
|
+
coverage: result[:coverage]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# A GistStore.record failure must never fail an otherwise-successful
|
|
56
|
+
# publish -- the gist is already live at result[:url] regardless of
|
|
57
|
+
# whether we can remember it locally. Mirrors how DocStore.save
|
|
58
|
+
# swallows a DocRoots.record failure (doc_store.rb:151) rather than
|
|
59
|
+
# raising it back at the caller.
|
|
60
|
+
begin
|
|
61
|
+
GistStore.record(
|
|
62
|
+
base_name, id: result[:id], url: result[:url], revisions: result[:revisions]
|
|
63
|
+
)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
response[:warning] = "gist saved, but recording it locally failed: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Stale-id recovery (see GistPublisher#publish): result[:forget_stale_id],
|
|
69
|
+
# when present, is the OLD gist id that 404'd on PATCH -- there is
|
|
70
|
+
# nothing further to clean up for it. GistStore is keyed by doc NAME,
|
|
71
|
+
# not gist id, and GistStore.record above already overwrote this
|
|
72
|
+
# doc's one entry with the freshly-minted id, so the stale id was
|
|
73
|
+
# never left behind under any key to forget.
|
|
74
|
+
response.to_json
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Shared ArgumentError->422 / StandardError->500 mapping for the
|
|
79
|
+
# org/rb/gist save-doc branches.
|
|
80
|
+
def rescue_save_errors
|
|
81
|
+
yield
|
|
82
|
+
rescue ArgumentError => e
|
|
83
|
+
halt 422, { ok: false, error: e.message }.to_json
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
halt 500, { ok: false, error: e.message }.to_json
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|