painapple-code 1.0.0__py3-none-any.whl
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.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""Containerized deployment bodies — run/stop/logs/shell/password/
|
|
2
|
+
extract/pull for docker-mode profiles and the ad-hoc ``--in-docker``
|
|
3
|
+
sandbox.
|
|
4
|
+
|
|
5
|
+
Everything here takes explicit ``(cfg: DockerSettings, profile_name)``
|
|
6
|
+
arguments — no module-level config loads — so the same bodies serve a
|
|
7
|
+
named profile, the root ad-hoc sandbox, and tests.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from painapple_code.cli.netinfo import detect_local_ips
|
|
18
|
+
from painapple_code.cli.ui import (
|
|
19
|
+
BOLD, DIM, GREEN, RESET, die, err, info, ok, print_credentials, say, warn,
|
|
20
|
+
)
|
|
21
|
+
from painapple_code.cli.deploy.config import DEFAULT_IMAGE, UPSTREAM_IMAGE
|
|
22
|
+
from painapple_code.cli.deploy.runtime import Runtime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# The ad-hoc `--in-docker` sandbox is the one deployment with no name to
|
|
26
|
+
# select it by, so its management commands carry the mode flag instead.
|
|
27
|
+
# Without it `painapple password` resolves to the HOST deployment and
|
|
28
|
+
# prints the wrong password (or dies claiming nothing has ever started)
|
|
29
|
+
# — and that string is what the container's own login page tells the
|
|
30
|
+
# user to run. Verbs absent here have no ad-hoc form and keep the plain
|
|
31
|
+
# root command.
|
|
32
|
+
_ADHOC_HINTS = {
|
|
33
|
+
"start": "painapple --in-docker",
|
|
34
|
+
"password": "painapple password --in-docker",
|
|
35
|
+
"logs": "painapple logs --in-docker",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hint(verb, profile=None):
|
|
40
|
+
"""The command a user types to do ``verb`` against this deployment —
|
|
41
|
+
profile verbs carry the name (`painapple logs work`), the ad-hoc
|
|
42
|
+
sandbox carries `--in-docker`."""
|
|
43
|
+
if profile:
|
|
44
|
+
return f"painapple {verb} {profile}"
|
|
45
|
+
return _ADHOC_HINTS.get(verb, f"painapple {verb}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ──── Network helpers ─────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def bootstrap_hosts(cfg):
|
|
51
|
+
"""Hosts to print bootstrap URLs for. 0.0.0.0 = every interface, so
|
|
52
|
+
list LAN IPs first (what a phone would use), loopback last."""
|
|
53
|
+
if cfg.listen_host == "0.0.0.0":
|
|
54
|
+
return [ip for ip, _ in detect_local_ips()] + ["127.0.0.1"]
|
|
55
|
+
return [cfg.listen_host]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def listen_scope(cfg):
|
|
59
|
+
"""Short human phrase for what the host bind exposes — this is the
|
|
60
|
+
HOST-side publish interface (`-p LISTEN_HOST:PORT:8765`), not the
|
|
61
|
+
container's internal bind (always 0.0.0.0:8765)."""
|
|
62
|
+
if cfg.listen_host == "0.0.0.0":
|
|
63
|
+
return "all interfaces — reachable on your LAN"
|
|
64
|
+
if cfg.listen_host in ("127.0.0.1", "::1", "localhost"):
|
|
65
|
+
return "localhost only — this machine"
|
|
66
|
+
return "this interface only"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ──── Password (reads the server's config volume) ────────────────────────
|
|
70
|
+
|
|
71
|
+
def _scalar_from_yaml(text, key="password"):
|
|
72
|
+
prefix = f"{key}:"
|
|
73
|
+
for line in text.splitlines():
|
|
74
|
+
if line.startswith(prefix):
|
|
75
|
+
return line.split(":", 1)[1].strip()
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _password_from_yaml(text):
|
|
80
|
+
return _scalar_from_yaml(text, "password")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _read_auth_config(cfg, rt):
|
|
84
|
+
"""Raw text of the container's auth config, or "" if unreachable."""
|
|
85
|
+
if cfg.config_is_bind():
|
|
86
|
+
f = Path(cfg.config_volume) / "config.yaml"
|
|
87
|
+
try:
|
|
88
|
+
return f.read_text(encoding="utf-8") if f.is_file() else ""
|
|
89
|
+
except OSError:
|
|
90
|
+
return ""
|
|
91
|
+
if rt.container_running(cfg.container):
|
|
92
|
+
out = rt.output("exec", cfg.container, "sh", "-c",
|
|
93
|
+
"cat /home/app/.config/painapple-code/config.yaml 2>/dev/null")
|
|
94
|
+
if _password_from_yaml(out):
|
|
95
|
+
return out
|
|
96
|
+
if rt.volume_exists(cfg.config_volume):
|
|
97
|
+
return rt.output("run", "--rm", "--entrypoint", "sh",
|
|
98
|
+
"-v", f"{cfg.config_volume}:/cfg:ro", cfg.image,
|
|
99
|
+
"-c", "cat /cfg/config.yaml 2>/dev/null")
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_password(cfg, rt):
|
|
104
|
+
return _password_from_yaml(_read_auth_config(cfg, rt))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_credentials(cfg, rt):
|
|
108
|
+
"""(password, api_token) in one read of the container's auth config.
|
|
109
|
+
|
|
110
|
+
api_token is "" on a config written by a pre-WP-02 build; callers fall
|
|
111
|
+
back to a bare URL rather than putting the password in a link.
|
|
112
|
+
"""
|
|
113
|
+
text = _read_auth_config(cfg, rt)
|
|
114
|
+
return _scalar_from_yaml(text, "password"), _scalar_from_yaml(text, "api_token")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def print_bootstrap_url(cfg, pw, profile=None, raw_tty=False, token=None):
|
|
118
|
+
"""The clickable ?tkn= URL block shown after a start / password.
|
|
119
|
+
|
|
120
|
+
raw_tty: when printing around an attached `docker run -it` the TTY
|
|
121
|
+
is in raw mode, so plain \\n stair-steps — use \\r\\n (harmless in
|
|
122
|
+
cooked mode).
|
|
123
|
+
"""
|
|
124
|
+
scheme = "https" if cfg.effective_tls() == "on" else "http"
|
|
125
|
+
nl = "\r\n" if raw_tty else "\n"
|
|
126
|
+
out = [""]
|
|
127
|
+
url_kind = "auto-login URL" if token else "URL"
|
|
128
|
+
out.append(f"{GREEN}✓{RESET} Open this {url_kind} once (cookie keeps you logged in):")
|
|
129
|
+
link = token or ""
|
|
130
|
+
for host in bootstrap_hosts(cfg):
|
|
131
|
+
base = f"{scheme}://{host}:{cfg.port}/"
|
|
132
|
+
out.append(f" {BOLD}{base}?tkn={link}{RESET}" if link else f" {BOLD}{base}{RESET}")
|
|
133
|
+
out.append(f" {BOLD}Password (login form): {pw}{RESET}")
|
|
134
|
+
out.append(f"{DIM} Reveal again later with: {hint('password', profile)}{RESET}")
|
|
135
|
+
sys.stdout.write(nl.join(out) + nl)
|
|
136
|
+
sys.stdout.flush()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ──── pull ────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def pull_image(cfg, rt, tag="latest", source=None):
|
|
142
|
+
"""Pull the upstream (or ``source``) image and retag it to
|
|
143
|
+
``cfg.image`` so the rest of the CLI finds it under its local name.
|
|
144
|
+
Returns True on success, False on a failed pull."""
|
|
145
|
+
if source:
|
|
146
|
+
pull_ref = source if ":" in source else f"{source}:{tag}"
|
|
147
|
+
else:
|
|
148
|
+
pull_ref = f"{UPSTREAM_IMAGE}:{tag}"
|
|
149
|
+
|
|
150
|
+
info(f"Pulling {pull_ref} via {rt.name}…")
|
|
151
|
+
if rt.run("pull", pull_ref).returncode != 0:
|
|
152
|
+
return False
|
|
153
|
+
# Name the version, always. A moving tag that has silently stopped
|
|
154
|
+
# moving is invisible otherwise — `painapple pull` reports success,
|
|
155
|
+
# the digest never changes, and the user runs a months-old build
|
|
156
|
+
# while every release note says otherwise. (It happened: :latest sat
|
|
157
|
+
# on v1.0.0-rc1 from July.) '' when the label is missing.
|
|
158
|
+
version = rt.label(pull_ref, "org.opencontainers.image.version")
|
|
159
|
+
ok(f"Pulled {pull_ref}" + (f" {DIM}(version {version}){RESET}" if version else ""))
|
|
160
|
+
|
|
161
|
+
if pull_ref != cfg.image:
|
|
162
|
+
rt.run("tag", pull_ref, cfg.image)
|
|
163
|
+
ok(f"Tagged {pull_ref} → {cfg.image}")
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cmd_pull(cfg, args):
|
|
168
|
+
rt = Runtime(cfg)
|
|
169
|
+
|
|
170
|
+
tag, explicit_source = "latest", None
|
|
171
|
+
i = 0
|
|
172
|
+
while i < len(args):
|
|
173
|
+
arg = args[i]
|
|
174
|
+
if arg in ("--source", "--image"):
|
|
175
|
+
if i + 1 >= len(args):
|
|
176
|
+
die(f"{arg} needs an image reference (e.g. ghcr.io/foo/bar:edge)")
|
|
177
|
+
explicit_source = args[i + 1]
|
|
178
|
+
i += 2
|
|
179
|
+
elif arg.startswith("-"):
|
|
180
|
+
die(f"Unknown flag: {arg}")
|
|
181
|
+
else:
|
|
182
|
+
tag = arg
|
|
183
|
+
i += 1
|
|
184
|
+
|
|
185
|
+
if not pull_image(cfg, rt, tag=tag, source=explicit_source):
|
|
186
|
+
die("Pull failed. Check the tag exists and you have network access.",
|
|
187
|
+
f" Available tags: https://hub.docker.com/r/{UPSTREAM_IMAGE}/tags")
|
|
188
|
+
|
|
189
|
+
# The published image bakes UID 1000. Newer tags re-stamp their user
|
|
190
|
+
# to the host's on start (or get remapped by podman), so the mismatch
|
|
191
|
+
# only bites on an older image under docker.
|
|
192
|
+
# Ordered cheapest-first: the uid probe may cost a throwaway container
|
|
193
|
+
# on an unlabeled image, and none of it matters off Linux/docker.
|
|
194
|
+
if (sys.platform.startswith("linux") and rt.name == "docker"
|
|
195
|
+
and not rt.image_adapts_uid(cfg.image)
|
|
196
|
+
and rt.image_user_ids(cfg.image)[0] not in (None, os.getuid())):
|
|
197
|
+
app_uid = rt.image_user_ids(cfg.image)[0]
|
|
198
|
+
say()
|
|
199
|
+
warn(f"This tag bakes UID {app_uid}; your host UID is {os.getuid()}.")
|
|
200
|
+
warn("The container won't be able to write your workspace or its own /data.")
|
|
201
|
+
warn("Pull a newer tag, or build locally so the UIDs match: "
|
|
202
|
+
"./painapple-docker.sh build (repo checkout)")
|
|
203
|
+
|
|
204
|
+
say()
|
|
205
|
+
say(f"{DIM}Next:{RESET} painapple --in-docker {DIM}(or: painapple start NAME "
|
|
206
|
+
f"for a docker-mode profile){RESET}")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ──── run ─────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def _host_uid():
|
|
213
|
+
"""The uid that owns bind mounts, or None where that isn't a
|
|
214
|
+
meaningful question — off Linux the mounts come through a VM and the
|
|
215
|
+
login uid isn't what the container sees."""
|
|
216
|
+
if sys.platform.startswith("linux") and hasattr(os, "getuid"):
|
|
217
|
+
return os.getuid()
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Identity:
|
|
222
|
+
"""How this launch lines the container's user up with the host user.
|
|
223
|
+
|
|
224
|
+
``userns`` extra flag for podman's user-namespace mapping
|
|
225
|
+
``run_user`` value for ``--user`` (None = the image's own default)
|
|
226
|
+
``env`` PAINAPPLE_UID/GID for an adapting entrypoint
|
|
227
|
+
``app_ids`` (uid, gid) the server ends up running as, in-container
|
|
228
|
+
``repair`` True when named volumes must be chowned from outside —
|
|
229
|
+
the mapping moved under them, or the image is too old
|
|
230
|
+
to put its own house in order
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, userns=None, run_user=None, env=None,
|
|
234
|
+
app_ids=(None, None), repair=False):
|
|
235
|
+
self.userns = userns
|
|
236
|
+
self.run_user = run_user
|
|
237
|
+
self.env = env or {}
|
|
238
|
+
self.app_ids = app_ids
|
|
239
|
+
self.repair = repair
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def plan_identity(cfg, rt, profile=None):
|
|
243
|
+
"""Decide the uid story for this launch — see :class:`Identity`.
|
|
244
|
+
|
|
245
|
+
``profile`` only shapes the remediation hint on the give-up path.
|
|
246
|
+
|
|
247
|
+
Everything the server is mounted FOR is owned by the host user: the
|
|
248
|
+
workspace it edits, ~/.claude, the server config dir, and /data
|
|
249
|
+
itself when a host profile rides along as a bind. The image bakes
|
|
250
|
+
its `app` user at a fixed uid (1000 unless you built with
|
|
251
|
+
``--build-arg USER_UID``), so on any host whose uid isn't that, the
|
|
252
|
+
container can't write a single one of them — it dies on boot with
|
|
253
|
+
`PermissionError: /data/logs/server.log` and, if /data happens to be
|
|
254
|
+
a named volume instead, silently can't edit your project.
|
|
255
|
+
|
|
256
|
+
Two mechanisms, one per runtime:
|
|
257
|
+
|
|
258
|
+
* **podman** (rootless) — remap the user namespace so the running
|
|
259
|
+
user lands ON the app uid (``keep-id:uid=…,gid=…``). Works with any
|
|
260
|
+
image, including already-pulled ones, and on every platform: even
|
|
261
|
+
podman machine on a Mac presents real uids from inside the VM.
|
|
262
|
+
* **docker** — hand the ids to the entrypoint, which re-stamps `app`
|
|
263
|
+
and drops privileges. Needs an image that advertises it can
|
|
264
|
+
(``io.painapple.uid-adapt``); older ones get a real error instead
|
|
265
|
+
of a crash loop. Linux only — Docker Desktop fakes bind ownership.
|
|
266
|
+
|
|
267
|
+
A NAMED VOLUME is a separate story from a bind and applies on every
|
|
268
|
+
platform: it keeps the uid of whichever container populated it, so a
|
|
269
|
+
data home outlives the image that made it and stops being writable.
|
|
270
|
+
That repair is driven by ``repair``.
|
|
271
|
+
"""
|
|
272
|
+
adapts = rt.image_adapts_uid(cfg.image)
|
|
273
|
+
app_uid, app_gid = rt.image_user_ids(cfg.image)
|
|
274
|
+
# Volumes need fixing from outside whenever the image can't do it
|
|
275
|
+
# itself — a current entrypoint chowns /data on boot, as root.
|
|
276
|
+
repair = not adapts and app_uid is not None
|
|
277
|
+
|
|
278
|
+
if rt.name == "podman":
|
|
279
|
+
# No os.getuid() here on purpose, and no platform check: keep-id
|
|
280
|
+
# means "map the user I'm running as", which on a Mac is the
|
|
281
|
+
# podman-machine VM's user rather than your login uid. Plain
|
|
282
|
+
# keep-id maps that user to its OWN id — exactly the mismatch
|
|
283
|
+
# when the image's `app` is some other uid — while the uid=/gid=
|
|
284
|
+
# form lands it ON `app`. When they already agree the two are
|
|
285
|
+
# identical, so this is safe to apply unconditionally.
|
|
286
|
+
can_map = app_uid is not None and rt.supports_userns_map()
|
|
287
|
+
userns = (f"--userns=keep-id:uid={app_uid},gid={app_gid}" if can_map
|
|
288
|
+
else "--userns=keep-id")
|
|
289
|
+
# An adapting image doesn't need the mapping — its entrypoint
|
|
290
|
+
# re-stamps `app` on its own — so only an old podman paired with
|
|
291
|
+
# an old image is genuinely stuck. (host_uid is None off Linux,
|
|
292
|
+
# where we can't know it and therefore can't rule the clash out.)
|
|
293
|
+
if not can_map and not adapts and app_uid not in (None, _host_uid()):
|
|
294
|
+
warn(f"podman is too old for `keep-id:uid=…` — the container runs as "
|
|
295
|
+
f"uid {app_uid} but the mounts are owned by someone else; "
|
|
296
|
+
f"writes will fail. Upgrade podman (≥4.3), pull a newer image, "
|
|
297
|
+
f"or rebuild with --build-arg USER_UID.")
|
|
298
|
+
# An adapting image can also repair a named volume from the
|
|
299
|
+
# inside, but only from root — and dropping back to `app` is that
|
|
300
|
+
# entrypoint's whole job, so this stays unprivileged.
|
|
301
|
+
return Identity(userns=userns, run_user="0" if adapts else None,
|
|
302
|
+
app_ids=(app_uid, app_gid), repair=repair)
|
|
303
|
+
|
|
304
|
+
# docker. Off Linux there's no host uid to line anything up WITH:
|
|
305
|
+
# Docker Desktop presents bind mounts through a VM that fakes
|
|
306
|
+
# ownership to match whoever asks, so only the volumes matter.
|
|
307
|
+
if not sys.platform.startswith("linux") or not hasattr(os, "getuid"):
|
|
308
|
+
return Identity(app_ids=(app_uid, app_gid), repair=repair)
|
|
309
|
+
|
|
310
|
+
host_uid, host_gid = os.getuid(), os.getgid()
|
|
311
|
+
|
|
312
|
+
# On Linux bind mounts carry real host ownership, and there's no
|
|
313
|
+
# namespace to bend — the entrypoint has to do the re-stamping.
|
|
314
|
+
if app_uid in (None, host_uid) or adapts:
|
|
315
|
+
env = {"PAINAPPLE_UID": str(host_uid), "PAINAPPLE_GID": str(host_gid)} if adapts else {}
|
|
316
|
+
return Identity(env=env, app_ids=(app_uid, app_gid), repair=repair)
|
|
317
|
+
|
|
318
|
+
# The runtime is a config key, not an env var — `RUNTIME=podman` only
|
|
319
|
+
# means anything to the build wrapper, so spell out the real command.
|
|
320
|
+
switch = (f"painapple profile set {profile} RUNTIME=podman" if profile
|
|
321
|
+
else "painapple setup")
|
|
322
|
+
die(f"This image runs as uid {app_uid}, but your files are owned by uid "
|
|
323
|
+
f"{host_uid} — the container could not write your workspace, ~/.claude, "
|
|
324
|
+
f"or its own /data.",
|
|
325
|
+
f" Fix it with any of:\n"
|
|
326
|
+
f" painapple pull {DIM}(newer images align on start){RESET}\n"
|
|
327
|
+
f" ./painapple-docker.sh build {DIM}(repo checkout — bakes "
|
|
328
|
+
f"USER_UID={host_uid}){RESET}\n"
|
|
329
|
+
f" {DIM}…or switch to podman, which remaps uids for you:{RESET}\n"
|
|
330
|
+
f" {switch}")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def prepare_volumes(cfg, rt, ident):
|
|
334
|
+
"""Hand named volumes to the uid the container will run as.
|
|
335
|
+
|
|
336
|
+
A volume keeps the ownership of whatever populated it, so it outlives
|
|
337
|
+
the image that made it: remap the namespace under it, rebuild with a
|
|
338
|
+
different USER_UID, or pull over a locally built tag, and the server
|
|
339
|
+
can no longer write its own data home. Idempotent — the runtime-side
|
|
340
|
+
check is a no-op when the ownership is already right.
|
|
341
|
+
|
|
342
|
+
Only for images that can't do it themselves; a current entrypoint
|
|
343
|
+
chowns /data from the inside, as root, before it drops privileges.
|
|
344
|
+
"""
|
|
345
|
+
if not ident.repair:
|
|
346
|
+
return
|
|
347
|
+
uid, gid = ident.app_ids
|
|
348
|
+
extra = [ident.userns] if ident.userns else []
|
|
349
|
+
for volume, is_bind in ((cfg.data_volume, cfg.data_is_bind()),
|
|
350
|
+
(cfg.config_volume, cfg.config_is_bind())):
|
|
351
|
+
# Binds keep host ownership on purpose — they're the user's own
|
|
352
|
+
# directories, and the remap is what makes them line up already.
|
|
353
|
+
if is_bind or not rt.volume_exists(volume):
|
|
354
|
+
continue
|
|
355
|
+
if not rt.chown_volume(cfg.image, volume, uid, gid, extra):
|
|
356
|
+
warn(f"Could not adjust ownership of volume '{volume}' — the server "
|
|
357
|
+
f"may fail to write /data.")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def build_run_argv(cfg, rt, detach, profile=None, ident=None):
|
|
361
|
+
"""The full `docker run …` argv. Pure assembly — no side effects —
|
|
362
|
+
so tests can pin it. ``ident`` comes from plan_identity(); omitted,
|
|
363
|
+
the uid handling stays at the plain `--userns=keep-id` default."""
|
|
364
|
+
selinux = rt.selinux_enforcing()
|
|
365
|
+
ident = ident or Identity()
|
|
366
|
+
|
|
367
|
+
def mount(spec, bind=True):
|
|
368
|
+
return f"{spec}:Z" if selinux and bind else spec
|
|
369
|
+
|
|
370
|
+
argv = ["run"]
|
|
371
|
+
# Foreground runs are ephemeral (--rm, you're watching); detached runs
|
|
372
|
+
# are durable (--restart unless-stopped, survive crash/reboot).
|
|
373
|
+
argv += ["-d", "--restart", "unless-stopped"] if detach else ["--rm", "-it"]
|
|
374
|
+
argv += ["--name", cfg.container,
|
|
375
|
+
"-e", f"PAINAPPLE_REVEAL_CMD={hint('password', profile)}",
|
|
376
|
+
"-p", f"{cfg.listen_host}:{cfg.port}:8765"]
|
|
377
|
+
for key, value in ident.env.items():
|
|
378
|
+
argv += ["-e", f"{key}={value}"]
|
|
379
|
+
if ident.run_user:
|
|
380
|
+
argv += ["--user", ident.run_user]
|
|
381
|
+
argv += ["-v", mount(f"{cfg.data_volume}:/data", bind=cfg.data_is_bind()),
|
|
382
|
+
"-v", mount(f"{cfg.config_volume}:/home/app/.config/painapple-code",
|
|
383
|
+
bind=cfg.config_is_bind()),
|
|
384
|
+
"-v", mount(f"{cfg.claude_home}:/home/app/.claude"),
|
|
385
|
+
"-v", mount(f"{cfg.effective_claude_json()}:/home/app/.claude.json")]
|
|
386
|
+
|
|
387
|
+
if cfg.workspace_mode == "multi":
|
|
388
|
+
for ws in cfg.workspaces:
|
|
389
|
+
argv += ["-v", mount(f"{ws}:/workspace/{Path(ws).name}")]
|
|
390
|
+
else:
|
|
391
|
+
argv += ["-v", mount(f"{cfg.workspace}:{cfg.container_mount()}")]
|
|
392
|
+
|
|
393
|
+
if rt.name == "podman":
|
|
394
|
+
argv += [ident.userns or "--userns=keep-id"]
|
|
395
|
+
|
|
396
|
+
# The served workspace is ALWAYS /workspace — the dir holding projects.
|
|
397
|
+
# In project mode the repo is mounted one level down (container_mount),
|
|
398
|
+
# so it shows up as the single pickable project on the welcome screen
|
|
399
|
+
# rather than the server treating the repo's own subdirs as projects.
|
|
400
|
+
argv += [cfg.image, "python", "-m", "painapple_code",
|
|
401
|
+
"--host", "0.0.0.0", "--port", "8765",
|
|
402
|
+
"--workspace", "/workspace"]
|
|
403
|
+
if cfg.instance_name:
|
|
404
|
+
argv += ["--instance-name", cfg.instance_name]
|
|
405
|
+
if cfg.accent:
|
|
406
|
+
argv += ["--accent", cfg.accent]
|
|
407
|
+
# Can't pass `auto` through: the server would resolve it against its
|
|
408
|
+
# own in-container bind (always 0.0.0.0) and force TLS on. We know the
|
|
409
|
+
# real reachability via listen_host, so resolve here.
|
|
410
|
+
argv += ["--tls", cfg.effective_tls()]
|
|
411
|
+
return argv
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _container_trouble(cfg, rt):
|
|
415
|
+
"""Why the container isn't coming up — '' when it looks healthy.
|
|
416
|
+
|
|
417
|
+
A container that dies on boot is restarted by ``--restart
|
|
418
|
+
unless-stopped`` forever, so "no auth config yet" and "crash-looping"
|
|
419
|
+
look identical from the outside unless we ask."""
|
|
420
|
+
if not rt.container_running(cfg.container):
|
|
421
|
+
return "it exited"
|
|
422
|
+
status = rt.container_status(cfg.container) or ""
|
|
423
|
+
if "restarting" in status.lower():
|
|
424
|
+
return "it keeps restarting"
|
|
425
|
+
count = rt.output("inspect", cfg.container,
|
|
426
|
+
"--format", "{{.RestartCount}}").strip()
|
|
427
|
+
if count.isdigit() and int(count) > 0:
|
|
428
|
+
return f"it has already restarted {count}×"
|
|
429
|
+
return ""
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _tail_container_logs(cfg, rt, n=20):
|
|
433
|
+
proc = rt.run("logs", "--tail", str(n), cfg.container,
|
|
434
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
435
|
+
for line in (proc.stdout + proc.stderr).splitlines()[-n:]:
|
|
436
|
+
say(f" {DIM}{line}{RESET}")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _wait_for_password(cfg, rt, timeout=10.0, watch=False):
|
|
440
|
+
"""Poll for the auth config the server writes on boot.
|
|
441
|
+
|
|
442
|
+
Returns (password, trouble). With ``watch`` (a detached start we just
|
|
443
|
+
launched), the container's own health is checked each round so a
|
|
444
|
+
crash-loop fails fast instead of masquerading as a slow boot."""
|
|
445
|
+
deadline = time.monotonic() + timeout
|
|
446
|
+
while time.monotonic() < deadline:
|
|
447
|
+
pw = get_password(cfg, rt)
|
|
448
|
+
if pw:
|
|
449
|
+
return pw, ""
|
|
450
|
+
if watch:
|
|
451
|
+
trouble = _container_trouble(cfg, rt)
|
|
452
|
+
if trouble:
|
|
453
|
+
return "", trouble
|
|
454
|
+
time.sleep(0.5)
|
|
455
|
+
return "", (_container_trouble(cfg, rt) if watch else "")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def run_container(cfg, detach, profile=None):
|
|
459
|
+
"""Validate + start the container. Returns an exit code (foreground
|
|
460
|
+
runs raise SystemExit with the container's own code)."""
|
|
461
|
+
rt = Runtime(cfg)
|
|
462
|
+
|
|
463
|
+
if cfg.workspace_mode == "multi":
|
|
464
|
+
if not cfg.workspaces:
|
|
465
|
+
die(f"No workspaces configured for multi mode. Run: "
|
|
466
|
+
f"{hint('setup', profile)}")
|
|
467
|
+
for ws in cfg.workspaces:
|
|
468
|
+
if not Path(ws).is_dir():
|
|
469
|
+
die(f"Workspace doesn't exist: {ws}")
|
|
470
|
+
else:
|
|
471
|
+
if not cfg.workspace:
|
|
472
|
+
die(f"No workspace configured. Run: {hint('setup', profile)}")
|
|
473
|
+
if not Path(cfg.workspace).is_dir():
|
|
474
|
+
die(f"Workspace doesn't exist: {cfg.workspace}")
|
|
475
|
+
|
|
476
|
+
# An explicitly configured runtime skips auto_detect's reachability
|
|
477
|
+
# check, so ask here — every query below fails soft ('' / False) and
|
|
478
|
+
# would otherwise misreport a dead daemon as a missing image.
|
|
479
|
+
if not rt.responds():
|
|
480
|
+
die(f"`{rt.binary} info` failed — the {rt.name} runtime isn't reachable.",
|
|
481
|
+
"Start the service (`systemctl --user start docker` / "
|
|
482
|
+
"`podman.socket`), check you're in the `docker` group, or point "
|
|
483
|
+
f"at another runtime: {hint('setup', profile)}")
|
|
484
|
+
|
|
485
|
+
# Missing image: the default local tag is what `painapple pull`
|
|
486
|
+
# produces, so fetch it automatically on first run. A custom image
|
|
487
|
+
# name keeps the fail-early error — we can't guess its registry, and
|
|
488
|
+
# letting the runtime auto-pull a local-only tag from Docker Hub
|
|
489
|
+
# dies confusingly.
|
|
490
|
+
if not rt.image_exists(cfg.image):
|
|
491
|
+
if cfg.image == DEFAULT_IMAGE:
|
|
492
|
+
info(f"Image '{cfg.image}' not found locally — fetching the "
|
|
493
|
+
f"prebuilt image (one time).")
|
|
494
|
+
if not pull_image(cfg, rt):
|
|
495
|
+
err("Pull failed. Check your network access, or pull by hand:")
|
|
496
|
+
say()
|
|
497
|
+
say(f" painapple pull {DIM}({UPSTREAM_IMAGE}:latest){RESET}")
|
|
498
|
+
say(f" painapple pull v0.3.0 {DIM}(pin a release){RESET}")
|
|
499
|
+
say(f" {DIM}(building from source needs a repo checkout: ./painapple-docker.sh build){RESET}")
|
|
500
|
+
raise SystemExit(1)
|
|
501
|
+
else:
|
|
502
|
+
err(f"Image '{cfg.image}' not found locally.")
|
|
503
|
+
say()
|
|
504
|
+
say(f" Pull the prebuilt image: painapple pull {DIM}({UPSTREAM_IMAGE}:latest){RESET}")
|
|
505
|
+
say(f" painapple pull v0.3.0 {DIM}(pin a release){RESET}")
|
|
506
|
+
say(f" {DIM}(building from source needs a repo checkout: ./painapple-docker.sh build){RESET}")
|
|
507
|
+
raise SystemExit(1)
|
|
508
|
+
|
|
509
|
+
Path(cfg.claude_home).mkdir(parents=True, exist_ok=True)
|
|
510
|
+
# The paired .claude.json must exist as a FILE before docker run —
|
|
511
|
+
# otherwise the engine creates a directory at the bind-mount target.
|
|
512
|
+
claude_json = Path(cfg.effective_claude_json())
|
|
513
|
+
if not claude_json.exists():
|
|
514
|
+
from painapple_code.paths import lock_mode
|
|
515
|
+
|
|
516
|
+
claude_json.touch()
|
|
517
|
+
lock_mode(claude_json, 0o600)
|
|
518
|
+
|
|
519
|
+
if rt.container_running(cfg.container):
|
|
520
|
+
# "Ensure running" semantics (and desktop-launcher parity): an
|
|
521
|
+
# already-serving container is success, not an error.
|
|
522
|
+
warn(f"Container '{cfg.container}' is already running.")
|
|
523
|
+
say(f"View logs: {hint('logs', profile)}")
|
|
524
|
+
say(f"Stop it: {hint('stop', profile)}")
|
|
525
|
+
return 0
|
|
526
|
+
if rt.container_exists(cfg.container):
|
|
527
|
+
rt.run("rm", cfg.container, capture_output=True)
|
|
528
|
+
|
|
529
|
+
# The container publishes on the HOST, so a taken port fails deep
|
|
530
|
+
# inside the runtime with a message that never mentions painapple
|
|
531
|
+
# ("pasta failed with exit code 1…") — and, being on stderr, it lands
|
|
532
|
+
# out of order among our own lines. Check it ourselves, first.
|
|
533
|
+
from painapple_code.cli.netinfo import port_holder, port_taken
|
|
534
|
+
reason = port_taken(cfg.listen_host, cfg.port)
|
|
535
|
+
if reason:
|
|
536
|
+
holder = port_holder(cfg.port)
|
|
537
|
+
err(f"Port {cfg.port} on {cfg.listen_host} is already in use — {reason}"
|
|
538
|
+
+ (f" (held by {holder})" if holder else ""))
|
|
539
|
+
say(f" {DIM}Pick another port: {hint('setup', profile)}{RESET}")
|
|
540
|
+
return 1
|
|
541
|
+
|
|
542
|
+
# Bind-mount dirs must exist so docker doesn't create them as root.
|
|
543
|
+
if cfg.data_is_bind():
|
|
544
|
+
Path(cfg.data_volume).mkdir(parents=True, exist_ok=True)
|
|
545
|
+
if cfg.config_is_bind():
|
|
546
|
+
Path(cfg.config_volume).mkdir(parents=True, exist_ok=True)
|
|
547
|
+
|
|
548
|
+
# Everything mounted in is owned by the host user — line the
|
|
549
|
+
# container's user up with it, or none of it is writable.
|
|
550
|
+
ident = plan_identity(cfg, rt, profile)
|
|
551
|
+
prepare_volumes(cfg, rt, ident)
|
|
552
|
+
|
|
553
|
+
info("Starting Painapple Code…")
|
|
554
|
+
if cfg.workspace_mode == "multi":
|
|
555
|
+
say(f" Workspaces: {DIM}(multi → /workspace){RESET}")
|
|
556
|
+
for ws in cfg.workspaces:
|
|
557
|
+
say(f" {DIM}↳ /workspace/{Path(ws).name} ← {ws}{RESET}")
|
|
558
|
+
else:
|
|
559
|
+
say(f" Workspace : {DIM}{cfg.workspace}{RESET} → {cfg.container_mount()} {DIM}({cfg.workspace_mode}){RESET}")
|
|
560
|
+
say(f" .claude : {DIM}{cfg.claude_home}{RESET} → /home/app/.claude")
|
|
561
|
+
say(f" .claude.json: {DIM}{cfg.effective_claude_json()}{RESET} → /home/app/.claude.json")
|
|
562
|
+
say(f" Data : {DIM}{cfg.data_volume}{RESET} ({'bind' if cfg.data_is_bind() else 'volume'}) → /data")
|
|
563
|
+
say(f" Config : {DIM}{cfg.config_volume}{RESET} ({'bind' if cfg.config_is_bind() else 'volume'}) → /home/app/.config/painapple-code")
|
|
564
|
+
say(f" Listen on : {DIM}{cfg.listen_host}:{cfg.port}{RESET} → container :8765 "
|
|
565
|
+
f"{DIM}(host bind — {listen_scope(cfg)}){RESET}")
|
|
566
|
+
if cfg.instance_name:
|
|
567
|
+
say(f" Instance : {DIM}{cfg.instance_name}{RESET}")
|
|
568
|
+
if cfg.accent:
|
|
569
|
+
say(f" Accent : {DIM}{cfg.accent}{RESET}")
|
|
570
|
+
# Show the password up-front on a re-up of an existing deployment
|
|
571
|
+
# (cheap bind-file read only; fresh deploys print it post-start).
|
|
572
|
+
if cfg.config_is_bind():
|
|
573
|
+
existing_pw = get_password(cfg, rt)
|
|
574
|
+
if existing_pw:
|
|
575
|
+
say(f" Password : {BOLD}{existing_pw}{RESET}")
|
|
576
|
+
say()
|
|
577
|
+
|
|
578
|
+
argv = build_run_argv(cfg, rt, detach, profile=profile, ident=ident)
|
|
579
|
+
|
|
580
|
+
if detach:
|
|
581
|
+
sys.stdout.flush() # keep the runtime's own stderr in order
|
|
582
|
+
result = rt.run(*argv)
|
|
583
|
+
if result.returncode != 0:
|
|
584
|
+
err(f"{rt.name} could not start {cfg.container} "
|
|
585
|
+
f"(exit {result.returncode}) — see its message above.")
|
|
586
|
+
return result.returncode
|
|
587
|
+
say()
|
|
588
|
+
info("Waiting for the server to write its auth config…")
|
|
589
|
+
pw, trouble = _wait_for_password(cfg, rt, watch=True)
|
|
590
|
+
if pw:
|
|
591
|
+
print_bootstrap_url(cfg, pw, profile=profile,
|
|
592
|
+
token=get_credentials(cfg, rt)[1])
|
|
593
|
+
elif trouble:
|
|
594
|
+
err(f"{cfg.container} didn't come up — {trouble}. Last output:")
|
|
595
|
+
_tail_container_logs(cfg, rt)
|
|
596
|
+
say(f" {DIM}Full log: {hint('logs', profile)}{RESET}")
|
|
597
|
+
return 1
|
|
598
|
+
else:
|
|
599
|
+
warn(f"Timed out waiting for auth config. Try: "
|
|
600
|
+
f"{hint('logs', profile)} or {hint('password', profile)}")
|
|
601
|
+
return 0
|
|
602
|
+
|
|
603
|
+
# Foreground: the server's own banner shows the in-container bind
|
|
604
|
+
# (0.0.0.0:8765) which isn't reachable from the host, so a background
|
|
605
|
+
# poller prints the real clickable URL once config.yaml appears.
|
|
606
|
+
def _poll_and_print():
|
|
607
|
+
pw, _trouble = _wait_for_password(cfg, rt)
|
|
608
|
+
if pw:
|
|
609
|
+
time.sleep(0.5) # let the server banner finish printing
|
|
610
|
+
print_bootstrap_url(cfg, pw, profile=profile, raw_tty=True,
|
|
611
|
+
token=get_credentials(cfg, rt)[1])
|
|
612
|
+
|
|
613
|
+
threading.Thread(target=_poll_and_print, daemon=True).start()
|
|
614
|
+
result = rt.run(*argv)
|
|
615
|
+
raise SystemExit(result.returncode)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ──── Lifecycle ───────────────────────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
def stop_container(cfg, profile=None):
|
|
621
|
+
rt = Runtime(cfg)
|
|
622
|
+
if rt.container_running(cfg.container):
|
|
623
|
+
info(f"Stopping {cfg.container}…")
|
|
624
|
+
rt.run("stop", cfg.container)
|
|
625
|
+
ok("Stopped")
|
|
626
|
+
return 0
|
|
627
|
+
warn(f"Container '{cfg.container}' is not running")
|
|
628
|
+
return 0
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def remove_container(cfg):
|
|
632
|
+
"""Stop + rm — used by restart so the name is free again."""
|
|
633
|
+
rt = Runtime(cfg)
|
|
634
|
+
if rt.container_exists(cfg.container):
|
|
635
|
+
rt.run("stop", cfg.container, capture_output=True)
|
|
636
|
+
rt.run("rm", cfg.container, capture_output=True)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def cmd_logs(cfg):
|
|
640
|
+
Runtime(cfg).exec_interactive("logs", "-f", cfg.container)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def cmd_shell(cfg, profile=None):
|
|
644
|
+
rt = Runtime(cfg)
|
|
645
|
+
if not rt.container_running(cfg.container):
|
|
646
|
+
die(f"Container '{cfg.container}' is not running. Start it with: "
|
|
647
|
+
f"{hint('start', profile)}")
|
|
648
|
+
info(f"Opening shell in {cfg.container}…")
|
|
649
|
+
# -u app: the image's last USER is root (the entrypoint needs it to
|
|
650
|
+
# align uids), so an exec would otherwise land you as root and let
|
|
651
|
+
# you scatter root-owned files through the bind-mounted workspace.
|
|
652
|
+
# The name resolves against the container's own /etc/passwd, so it
|
|
653
|
+
# follows whatever uid the entrypoint settled on.
|
|
654
|
+
# The image's default user shell is zsh; `docker exec` doesn't read
|
|
655
|
+
# /etc/passwd for the default, so name it explicitly.
|
|
656
|
+
rt.exec_interactive("exec", "-it", "-u", "app", cfg.container, "zsh")
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def cmd_claude_login(cfg, profile=None):
|
|
660
|
+
rt = Runtime(cfg)
|
|
661
|
+
if not rt.container_running(cfg.container):
|
|
662
|
+
die(f"Container '{cfg.container}' is not running. Start it with: "
|
|
663
|
+
f"{hint('start', profile)}")
|
|
664
|
+
info(f"Running 'claude login' inside {cfg.container}…")
|
|
665
|
+
say(f"{DIM}(this writes credentials to {cfg.claude_home}, not your host's ~/.claude){RESET}")
|
|
666
|
+
# -u app so the credentials land owned by you, not root — see cmd_shell.
|
|
667
|
+
rt.exec_interactive("exec", "-it", "-u", "app", cfg.container, "claude", "login")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def cmd_password(cfg, profile=None):
|
|
671
|
+
rt = Runtime(cfg)
|
|
672
|
+
pw = get_password(cfg, rt)
|
|
673
|
+
if not pw:
|
|
674
|
+
die(f"No password found. Has the server ever started? "
|
|
675
|
+
f"({hint('start', profile)})")
|
|
676
|
+
scheme = "https" if cfg.effective_tls() == "on" else "http"
|
|
677
|
+
_, token = get_credentials(cfg, rt)
|
|
678
|
+
urls = [f"{scheme}://{host}:{cfg.port}/" + (f"?tkn={token}" if token else "")
|
|
679
|
+
for host in bootstrap_hosts(cfg)]
|
|
680
|
+
print_credentials(urls, pw, token)
|
|
681
|
+
return 0
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def cmd_extract(cfg, args, profile=None):
|
|
685
|
+
rt = Runtime(cfg)
|
|
686
|
+
dest = Path(args[0] if args else "./painapple-data-export").expanduser()
|
|
687
|
+
|
|
688
|
+
if cfg.data_is_bind():
|
|
689
|
+
info(f"Data is already a bind mount: {cfg.data_volume}")
|
|
690
|
+
say(" Nothing to extract — copy directly with:")
|
|
691
|
+
say(f' cp -a "{cfg.data_volume}/." "{dest}/"')
|
|
692
|
+
return 0
|
|
693
|
+
|
|
694
|
+
if not rt.volume_exists(cfg.data_volume):
|
|
695
|
+
die(f"Volume '{cfg.data_volume}' not found. Has the container ever run?")
|
|
696
|
+
|
|
697
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
698
|
+
dest = dest.resolve()
|
|
699
|
+
info(f"Extracting volume '{cfg.data_volume}' → {dest}")
|
|
700
|
+
|
|
701
|
+
# Stream tar OUT of the container instead of bind-mounting dest —
|
|
702
|
+
# avoids UID-mapping headaches. The container only READS the volume;
|
|
703
|
+
# untar runs on the host and creates files owned by you.
|
|
704
|
+
reader = subprocess.Popen(
|
|
705
|
+
rt.argv("run", "--rm", "--entrypoint", "sh",
|
|
706
|
+
"-v", f"{cfg.data_volume}:/src:ro", cfg.image,
|
|
707
|
+
"-c", "tar -cf - -C /src ."),
|
|
708
|
+
stdout=subprocess.PIPE)
|
|
709
|
+
untar = subprocess.run(["tar", "-xf", "-", "-C", str(dest)], stdin=reader.stdout)
|
|
710
|
+
reader.stdout.close()
|
|
711
|
+
if reader.wait() != 0 or untar.returncode != 0:
|
|
712
|
+
die("Extract failed — see errors above.")
|
|
713
|
+
ok(f"Extracted to: {dest}")
|
|
714
|
+
say(f'{DIM} Tip: tar it up with: tar czf painapple-data.tgz -C "{dest}" .{RESET}')
|
|
715
|
+
return 0
|