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,125 @@
|
|
|
1
|
+
"""Host network introspection shared by the setup wizards and the
|
|
2
|
+
container launch path. Import-cheap: stdlib plus psutil, both imported
|
|
3
|
+
inside the functions so importing this module stays nearly free."""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def detect_local_ips():
|
|
9
|
+
"""[(ip, interface)] for every non-loopback IPv4 the host has.
|
|
10
|
+
|
|
11
|
+
psutil rather than parsing `ip`/`ifconfig`: neither exists on Windows,
|
|
12
|
+
and ipconfig's output is localized (and OEM-encoded), so scraping it
|
|
13
|
+
would break on any non-English install. psutil is a hard dependency
|
|
14
|
+
(requirements.txt, no environment marker), so there is no fallback to
|
|
15
|
+
keep — the old scrape sat behind an ImportError that cannot fire.
|
|
16
|
+
"""
|
|
17
|
+
import socket
|
|
18
|
+
|
|
19
|
+
import psutil
|
|
20
|
+
|
|
21
|
+
results = []
|
|
22
|
+
try:
|
|
23
|
+
stats = psutil.net_if_stats()
|
|
24
|
+
for iface, addrs in psutil.net_if_addrs().items():
|
|
25
|
+
st = stats.get(iface)
|
|
26
|
+
if st is not None and not st.isup:
|
|
27
|
+
continue
|
|
28
|
+
for addr in addrs:
|
|
29
|
+
if addr.family != socket.AF_INET:
|
|
30
|
+
continue
|
|
31
|
+
ip = addr.address
|
|
32
|
+
if ip.startswith("127.") or iface in ("lo", "lo0"):
|
|
33
|
+
continue
|
|
34
|
+
results.append((ip, iface))
|
|
35
|
+
except Exception:
|
|
36
|
+
pass # exotic/unsupported interface table — report what we got
|
|
37
|
+
return results
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def port_taken(host, port):
|
|
41
|
+
"""Reason string when `host:port` can't be bound, '' when it's free.
|
|
42
|
+
|
|
43
|
+
A plain bind test — catches ANY holder, unlike a scan of painapple's
|
|
44
|
+
own process table. Used by the server's own pre-flight and by
|
|
45
|
+
`painapple start` so the failure is reported before a detached child
|
|
46
|
+
inherits it. Unresolvable host counts as taken (with the reason)."""
|
|
47
|
+
import socket
|
|
48
|
+
try:
|
|
49
|
+
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
50
|
+
except socket.gaierror as e:
|
|
51
|
+
return f"cannot resolve host {host!r} ({e})"
|
|
52
|
+
family, socktype, proto, _canon, sockaddr = infos[0]
|
|
53
|
+
with socket.socket(family, socktype, proto) as sock:
|
|
54
|
+
# SO_REUSEADDR means opposite things on the two platforms. On POSIX
|
|
55
|
+
# it only skips the TIME_WAIT guard, which is what we want here. On
|
|
56
|
+
# Windows it permits binding a port another socket is ACTIVELY
|
|
57
|
+
# listening on (Microsoft's documented port-hijacking behavior) —
|
|
58
|
+
# so this probe would report "free" while a server was running, and
|
|
59
|
+
# every caller (boot pre-flight, `painapple start`, the container
|
|
60
|
+
# launcher) would happily start a second instance on the same port,
|
|
61
|
+
# after which Windows routes new connections to whichever bound
|
|
62
|
+
# last. Setting nothing on win32 gives the strict bind we need.
|
|
63
|
+
if sys.platform != "win32":
|
|
64
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
65
|
+
try:
|
|
66
|
+
sock.bind(sockaddr)
|
|
67
|
+
except OSError as e:
|
|
68
|
+
return e.strerror or str(e)
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _listener_pid(port):
|
|
73
|
+
"""PID of whatever holds the listening socket on `port`, or None.
|
|
74
|
+
Never raises — every caller is decoration."""
|
|
75
|
+
try:
|
|
76
|
+
import psutil
|
|
77
|
+
for conn in psutil.net_connections(kind="inet"):
|
|
78
|
+
if (conn.status == psutil.CONN_LISTEN and conn.laddr
|
|
79
|
+
and conn.laddr.port == int(port) and conn.pid):
|
|
80
|
+
return conn.pid
|
|
81
|
+
except Exception:
|
|
82
|
+
pass # AccessDenied for other users' sockets
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def port_holder(port):
|
|
87
|
+
"""Best-effort description of what already listens on `port` — the
|
|
88
|
+
painapple instance if it's one of ours, else whatever ss/lsof names.
|
|
89
|
+
'' when nothing can be determined. Never raises (decoration only)."""
|
|
90
|
+
try:
|
|
91
|
+
from painapple_code.cli.list_cmd import local_servers
|
|
92
|
+
rows = [r for r in local_servers() if str(r["port"]) == str(port)]
|
|
93
|
+
# One server can present as SEVERAL rows on Windows: the pipx
|
|
94
|
+
# console-script launcher (painapple.exe), the python.exe it
|
|
95
|
+
# spawns, and uvicorn's reload child all carry the same argv, so
|
|
96
|
+
# all three match. Picking the first gave whichever the process
|
|
97
|
+
# scan happened to list first — usually the launcher shim, whose
|
|
98
|
+
# cwd is %TEMP%, so the error blamed a real port conflict on a
|
|
99
|
+
# workspace the user had never heard of. Prefer the row that
|
|
100
|
+
# actually owns the listening socket.
|
|
101
|
+
listener = _listener_pid(port)
|
|
102
|
+
row = next((r for r in rows if r["pid"] == listener), None)
|
|
103
|
+
if row is None and rows:
|
|
104
|
+
row = rows[0]
|
|
105
|
+
if row:
|
|
106
|
+
label = row.get("name") or "unnamed instance"
|
|
107
|
+
return (f"painapple '{label}' (pid {row['pid']}, "
|
|
108
|
+
f"workspace {row.get('workspace') or '?'})")
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
# psutil: works on all three platforms and needs no ss/lsof. (On
|
|
113
|
+
# Windows neither exists, so the holder was always reported as unknown
|
|
114
|
+
# — the user got "port in use" with no way to tell what by.) The ss/
|
|
115
|
+
# lsof scrape that used to follow this was unreachable-by-design
|
|
116
|
+
# fallback: psutil is a hard dependency, so it only ever ran when
|
|
117
|
+
# psutil ALSO failed, in which case ss wouldn't have known better.
|
|
118
|
+
holder = _listener_pid(port)
|
|
119
|
+
if holder is not None:
|
|
120
|
+
try:
|
|
121
|
+
import psutil
|
|
122
|
+
return f"{psutil.Process(holder).name()} (pid {holder})"
|
|
123
|
+
except Exception:
|
|
124
|
+
return f"pid {holder}"
|
|
125
|
+
return ""
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Unified profile store — named deployments with a run mode.
|
|
2
|
+
|
|
3
|
+
A profile is a named, independent deployment at
|
|
4
|
+
``~/.painapple-code/profiles/NAME/profile.yaml`` whose ``mode`` says
|
|
5
|
+
HOW it runs:
|
|
6
|
+
|
|
7
|
+
* ``mode: host`` — a local server. The profile dir IS its data home
|
|
8
|
+
(sessions, shadow DB, logs, config.json) — isolation is mandatory,
|
|
9
|
+
not cosmetic, since DuckDB is single-writer. Running it repoints
|
|
10
|
+
``PAINAPPLE_CODE_HOME`` at the dir before the server boots
|
|
11
|
+
(:func:`activate`); every path helper follows for free.
|
|
12
|
+
* ``mode: docker`` — a container sandbox. Only ``profile.yaml`` lives
|
|
13
|
+
in the profile dir; data lives in the container's volume (``/data``).
|
|
14
|
+
``claude_home`` defaults to the shared ``shared/.claude`` so one
|
|
15
|
+
login serves every sandbox.
|
|
16
|
+
|
|
17
|
+
Shared keys use the serve vocabulary (``host`` = bind, ``tls``,
|
|
18
|
+
``port``, ``workspace``, ``instance_name``, ``accent``); docker mode
|
|
19
|
+
adds its own (image, container, volumes, workspace_mode, …) — see
|
|
20
|
+
cli/deploy/config.py for that half of the model.
|
|
21
|
+
|
|
22
|
+
Migration: :func:`ensure_migrated` adopts the two pre-unification
|
|
23
|
+
stores — ``serve-profiles/NAME/serve.yaml`` (moved wholesale; a
|
|
24
|
+
symlink is left behind so hand-written service units keep working) and
|
|
25
|
+
``docker-profiles/NAME/docker.yaml`` — plus the root ``docker.yaml``
|
|
26
|
+
(runtime defaults merge into serve.yaml; the deployment itself is
|
|
27
|
+
adopted as a profile only if its container/volume actually exists).
|
|
28
|
+
Idempotent and loud.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import shutil
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from painapple_code.cli import serve_config
|
|
37
|
+
from painapple_code.cli.serve_config import (
|
|
38
|
+
HOST_KEYS, LEGACY_DOCKER_PROFILES_DIR, LEGACY_SERVE_PROFILES_DIR,
|
|
39
|
+
PROFILES_DIR, root_home,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
PROFILE_FILE = "profile.yaml"
|
|
43
|
+
MODES = ("host", "docker")
|
|
44
|
+
|
|
45
|
+
_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$")
|
|
46
|
+
|
|
47
|
+
# 'default' means the flag-less root deployment in every verb — a
|
|
48
|
+
# profile by that name could never be addressed.
|
|
49
|
+
_RESERVED_NAMES = {"default"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def valid_name(name):
|
|
53
|
+
name = (name or "").strip()
|
|
54
|
+
return bool(_PROFILE_RE.match(name)) and name not in _RESERVED_NAMES
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def profiles_root(root=None):
|
|
58
|
+
return (root or root_home()) / PROFILES_DIR
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def profile_home(name, root=None):
|
|
62
|
+
return profiles_root(root) / name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def profile_path(name, root=None):
|
|
66
|
+
return profile_home(name, root) / PROFILE_FILE
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def exists(name, root=None):
|
|
70
|
+
return profile_path(name, root).is_file()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_profiles(root=None):
|
|
74
|
+
"""Named profiles that have a profile.yaml on disk (sorted)."""
|
|
75
|
+
base = profiles_root(root)
|
|
76
|
+
if not base.is_dir():
|
|
77
|
+
return []
|
|
78
|
+
return sorted(p.name for p in base.iterdir()
|
|
79
|
+
if p.is_dir() and (p / PROFILE_FILE).is_file())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Profile:
|
|
83
|
+
"""One loaded profile: ``name``, ``mode``, raw ``data`` dict, and
|
|
84
|
+
the load ``problems`` list (unknown/bad values, reported not fatal)."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, name, mode, data, problems=None):
|
|
87
|
+
self.name = name
|
|
88
|
+
self.mode = mode
|
|
89
|
+
self.data = data
|
|
90
|
+
self.problems = problems or []
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_docker(self):
|
|
94
|
+
return self.mode == "docker"
|
|
95
|
+
|
|
96
|
+
def home(self, root=None):
|
|
97
|
+
return profile_home(self.name, root)
|
|
98
|
+
|
|
99
|
+
def host_values(self):
|
|
100
|
+
"""Validated host-vocabulary subset (what the server layers /
|
|
101
|
+
what list & lifecycle read for a host profile)."""
|
|
102
|
+
values = {}
|
|
103
|
+
for key in HOST_KEYS:
|
|
104
|
+
raw = self.data.get(key)
|
|
105
|
+
if raw in (None, ""):
|
|
106
|
+
continue
|
|
107
|
+
try:
|
|
108
|
+
values[key] = serve_config._check(key, raw)
|
|
109
|
+
except ValueError:
|
|
110
|
+
continue
|
|
111
|
+
return values
|
|
112
|
+
|
|
113
|
+
def docker_settings(self):
|
|
114
|
+
"""DockerSettings for a docker-mode profile (lazy import — keeps
|
|
115
|
+
this module light for the activation path)."""
|
|
116
|
+
from painapple_code.cli.deploy.config import settings_from_data
|
|
117
|
+
return settings_from_data(self.data, profile=self.name)
|
|
118
|
+
|
|
119
|
+
def label(self):
|
|
120
|
+
return self.data.get("instance_name") or self.name
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load(name, root=None):
|
|
124
|
+
"""Load ``profiles/NAME/profile.yaml`` → :class:`Profile`. Returns
|
|
125
|
+
None when the profile doesn't exist. Never raises on bad content —
|
|
126
|
+
problems are collected on the returned object."""
|
|
127
|
+
path = profile_path(name, root)
|
|
128
|
+
if not path.is_file():
|
|
129
|
+
return None
|
|
130
|
+
import yaml
|
|
131
|
+
problems = []
|
|
132
|
+
try:
|
|
133
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
134
|
+
except (OSError, yaml.YAMLError) as e:
|
|
135
|
+
return Profile(name, "host", {}, [f"unreadable ({e})"])
|
|
136
|
+
if not isinstance(data, dict):
|
|
137
|
+
return Profile(name, "host", {}, ["not a key: value mapping"])
|
|
138
|
+
mode = str(data.pop("mode", "host") or "host").strip()
|
|
139
|
+
if mode not in MODES:
|
|
140
|
+
problems.append(f"unknown mode {mode!r} — treating as 'host'")
|
|
141
|
+
mode = "host"
|
|
142
|
+
return Profile(name, mode, data, problems)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def save(name, mode, data, root=None):
|
|
146
|
+
"""Write ``profiles/NAME/profile.yaml``. ``data`` keys with empty
|
|
147
|
+
values are dropped. Returns the path written."""
|
|
148
|
+
if not valid_name(name):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Bad profile name: {name!r} (letters/digits/._- only, max 32 chars)")
|
|
151
|
+
if mode not in MODES:
|
|
152
|
+
raise ValueError(f"Bad profile mode: {mode!r}")
|
|
153
|
+
import yaml
|
|
154
|
+
path = profile_path(name, root)
|
|
155
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
kept = {"mode": mode}
|
|
157
|
+
for key, value in data.items():
|
|
158
|
+
if value in (None, "", []):
|
|
159
|
+
continue
|
|
160
|
+
kept[key] = value
|
|
161
|
+
body = yaml.safe_dump(kept, default_flow_style=False, sort_keys=False)
|
|
162
|
+
path.write_text(
|
|
163
|
+
f"# Profile '{name}' — written by `painapple setup {name}`, editable by hand.\n"
|
|
164
|
+
"# mode: host (local server; this dir is its data home) or docker (container).\n"
|
|
165
|
+
+ body, encoding="utf-8")
|
|
166
|
+
from painapple_code.paths import lock_mode
|
|
167
|
+
lock_mode(path, 0o600) # icacls on win32; chmod is a no-op there
|
|
168
|
+
return path
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_home(name, root):
|
|
172
|
+
"""The data home ``--profile NAME`` resolves to, given the launching
|
|
173
|
+
process's own PAINAPPLE_CODE_HOME (``root``). Pure path arithmetic,
|
|
174
|
+
creating nothing — :func:`activate` uses it for this process, and the
|
|
175
|
+
fleet-view scan uses it to reproduce the same answer for SOMEONE
|
|
176
|
+
ELSE's process from its argv. Unusable names fall back to ``root``.
|
|
177
|
+
"""
|
|
178
|
+
name = (name or "").strip()
|
|
179
|
+
root = Path(root)
|
|
180
|
+
if name in ("", "default") or not valid_name(name):
|
|
181
|
+
return root
|
|
182
|
+
if root.name == name and root.parent.name in (PROFILES_DIR,
|
|
183
|
+
LEGACY_SERVE_PROFILES_DIR):
|
|
184
|
+
# PAINAPPLE_CODE_HOME already points AT this profile's home (a
|
|
185
|
+
# spawner — `painapple start`, a service unit — exported both
|
|
186
|
+
# env vars). Re-appending profiles/NAME would nest a second home
|
|
187
|
+
# inside the first; adopt the given home instead.
|
|
188
|
+
return root
|
|
189
|
+
return root / PROFILES_DIR / name
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def activate(name):
|
|
193
|
+
"""Repoint PAINAPPLE_CODE_HOME at a HOST profile's data home
|
|
194
|
+
(created if absent) and record the name in PAINAPPLE_PROFILE, so
|
|
195
|
+
every downstream path helper — and the config layering — targets
|
|
196
|
+
the profile. ``''``/``default`` is a no-op (the root deployment).
|
|
197
|
+
Returns the home Path. Raises ValueError on a bad name."""
|
|
198
|
+
name = (name or "").strip()
|
|
199
|
+
if name in ("", "default"):
|
|
200
|
+
return serve_config.home_root()
|
|
201
|
+
if not valid_name(name):
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"Bad profile name: {name!r} (letters/digits/._- only, max 32 chars)")
|
|
204
|
+
home = resolve_home(name, serve_config.home_root())
|
|
205
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
os.environ["PAINAPPLE_CODE_HOME"] = str(home)
|
|
207
|
+
os.environ["PAINAPPLE_PROFILE"] = name
|
|
208
|
+
return home
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ──── Migration (serve-profiles/ + docker-profiles/ + root docker.yaml) ──
|
|
212
|
+
|
|
213
|
+
_migrated_this_process = False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _free_name(name, taken):
|
|
217
|
+
if name not in taken and not exists(name):
|
|
218
|
+
return name
|
|
219
|
+
for candidate in (f"{name}-docker", *(f"{name}-{i}" for i in range(2, 20))):
|
|
220
|
+
if candidate not in taken and not exists(candidate):
|
|
221
|
+
return candidate
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _adopt_serve_profile(root, name, notes):
|
|
226
|
+
src = root / LEGACY_SERVE_PROFILES_DIR / name
|
|
227
|
+
if exists(name, root):
|
|
228
|
+
notes.append(f"'{name}': profiles/{name} already exists — "
|
|
229
|
+
f"left {src} untouched")
|
|
230
|
+
return
|
|
231
|
+
dst = profile_home(name, root)
|
|
232
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
import yaml
|
|
234
|
+
try:
|
|
235
|
+
data = yaml.safe_load((src / "serve.yaml").read_text(encoding="utf-8")) or {}
|
|
236
|
+
except (OSError, yaml.YAMLError) as e:
|
|
237
|
+
notes.append(f"'{name}': unreadable serve.yaml ({e}) — skipped")
|
|
238
|
+
return
|
|
239
|
+
if not isinstance(data, dict):
|
|
240
|
+
data = {}
|
|
241
|
+
# Move the WHOLE dir — it's the profile's data home (sessions,
|
|
242
|
+
# DuckDB, logs ride along).
|
|
243
|
+
shutil.move(str(src), str(dst))
|
|
244
|
+
(dst / "serve.yaml").unlink(missing_ok=True)
|
|
245
|
+
save(name, "host", data, root)
|
|
246
|
+
# Leave a symlink so hand-written service units / scripts that
|
|
247
|
+
# exported PAINAPPLE_CODE_HOME=…/serve-profiles/NAME keep working.
|
|
248
|
+
try:
|
|
249
|
+
src.symlink_to(Path("..") / PROFILES_DIR / name)
|
|
250
|
+
except OSError:
|
|
251
|
+
pass
|
|
252
|
+
notes.append(f"adopted serve profile '{name}' → profiles/{name} (mode: host)")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _adopt_docker_profile(root, name, notes):
|
|
256
|
+
src = root / LEGACY_DOCKER_PROFILES_DIR / name
|
|
257
|
+
legacy_yaml = src / "docker.yaml"
|
|
258
|
+
if not legacy_yaml.is_file():
|
|
259
|
+
notes.append(f"'{name}': wrapper.conf-only docker profile — not "
|
|
260
|
+
f"adopted (re-create with: painapple setup {name})")
|
|
261
|
+
return
|
|
262
|
+
target = _free_name(name, set())
|
|
263
|
+
if target is None:
|
|
264
|
+
notes.append(f"'{name}': no free profile name — skipped")
|
|
265
|
+
return
|
|
266
|
+
import yaml
|
|
267
|
+
try:
|
|
268
|
+
data = yaml.safe_load(legacy_yaml.read_text(encoding="utf-8")) or {}
|
|
269
|
+
except (OSError, yaml.YAMLError) as e:
|
|
270
|
+
notes.append(f"'{name}': unreadable docker.yaml ({e}) — skipped")
|
|
271
|
+
return
|
|
272
|
+
from painapple_code.cli.deploy.config import (
|
|
273
|
+
settings_from_data, settings_to_data)
|
|
274
|
+
settings = settings_from_data(data, profile=name)
|
|
275
|
+
save(target, "docker", settings_to_data(settings), root)
|
|
276
|
+
for leftover in ("docker.yaml", "wrapper.conf"):
|
|
277
|
+
(src / leftover).unlink(missing_ok=True)
|
|
278
|
+
try:
|
|
279
|
+
src.rmdir()
|
|
280
|
+
except OSError:
|
|
281
|
+
pass # stray files — leave the dir
|
|
282
|
+
suffix = "" if target == name else f" (renamed — '{name}' was taken)"
|
|
283
|
+
notes.append(f"adopted docker profile '{name}' → profiles/{target} "
|
|
284
|
+
f"(mode: docker){suffix}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _adopt_root_docker(root, notes):
|
|
288
|
+
legacy_yaml = root / "docker.yaml"
|
|
289
|
+
if not legacy_yaml.is_file():
|
|
290
|
+
return
|
|
291
|
+
import yaml
|
|
292
|
+
try:
|
|
293
|
+
data = yaml.safe_load(legacy_yaml.read_text(encoding="utf-8")) or {}
|
|
294
|
+
except (OSError, yaml.YAMLError) as e:
|
|
295
|
+
notes.append(f"root docker.yaml unreadable ({e}) — left in place")
|
|
296
|
+
return
|
|
297
|
+
if not isinstance(data, dict):
|
|
298
|
+
data = {}
|
|
299
|
+
|
|
300
|
+
# 1. Runtime/image defaults → the global serve.yaml (the
|
|
301
|
+
# `--in-docker` defaults), only where serve.yaml doesn't already
|
|
302
|
+
# have a value.
|
|
303
|
+
current, _ = serve_config.load()
|
|
304
|
+
merged = dict(current)
|
|
305
|
+
took = []
|
|
306
|
+
for key in serve_config.DEPLOY_KEYS:
|
|
307
|
+
if merged.get(key) in (None, "") and data.get(key) not in (None, ""):
|
|
308
|
+
merged[key] = str(data[key])
|
|
309
|
+
took.append(key)
|
|
310
|
+
if took:
|
|
311
|
+
try:
|
|
312
|
+
serve_config.save(merged)
|
|
313
|
+
notes.append("root docker.yaml: merged "
|
|
314
|
+
f"{', '.join(took)} into serve.yaml (--in-docker defaults)")
|
|
315
|
+
except ValueError as e:
|
|
316
|
+
notes.append(f"root docker.yaml: could not merge defaults ({e})")
|
|
317
|
+
|
|
318
|
+
# 2. The deployment itself becomes a profile only if it was real —
|
|
319
|
+
# a container or volume with its configured names exists.
|
|
320
|
+
from painapple_code.cli.deploy.config import (
|
|
321
|
+
settings_from_data, settings_to_data)
|
|
322
|
+
settings = settings_from_data(data, profile=None)
|
|
323
|
+
live = False
|
|
324
|
+
if shutil.which("docker") or shutil.which("podman") or settings.runtime:
|
|
325
|
+
try:
|
|
326
|
+
from painapple_code.cli.deploy.runtime import Runtime
|
|
327
|
+
rt = Runtime(settings)
|
|
328
|
+
live = (rt.container_exists(settings.container)
|
|
329
|
+
or (not settings.data_is_bind()
|
|
330
|
+
and rt.volume_exists(settings.data_volume)))
|
|
331
|
+
except (SystemExit, OSError):
|
|
332
|
+
live = False
|
|
333
|
+
if live:
|
|
334
|
+
target = _free_name("docker", set())
|
|
335
|
+
if target:
|
|
336
|
+
save(target, "docker", settings_to_data(settings), root)
|
|
337
|
+
notes.append(f"adopted the root docker deployment → "
|
|
338
|
+
f"profiles/{target} (mode: docker)")
|
|
339
|
+
|
|
340
|
+
# os.replace: a re-run after a partial migration already left a
|
|
341
|
+
# .migrated file behind — Path.rename would raise on win32 then.
|
|
342
|
+
os.replace(legacy_yaml, legacy_yaml.with_suffix(".yaml.migrated"))
|
|
343
|
+
notes.append("root docker.yaml → docker.yaml.migrated (superseded by "
|
|
344
|
+
"serve.yaml + profiles)")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def ensure_migrated(announce=None):
|
|
348
|
+
"""Adopt pre-unification stores into ``profiles/``. Idempotent;
|
|
349
|
+
cheap when there's nothing to do. ``announce`` is called with one
|
|
350
|
+
human line per action (default: cli.ui.info)."""
|
|
351
|
+
global _migrated_this_process
|
|
352
|
+
if _migrated_this_process:
|
|
353
|
+
return []
|
|
354
|
+
_migrated_this_process = True
|
|
355
|
+
|
|
356
|
+
root = root_home()
|
|
357
|
+
serve_dir = root / LEGACY_SERVE_PROFILES_DIR
|
|
358
|
+
docker_dir = root / LEGACY_DOCKER_PROFILES_DIR
|
|
359
|
+
serve_names = []
|
|
360
|
+
if serve_dir.is_dir():
|
|
361
|
+
serve_names = sorted(
|
|
362
|
+
p.name for p in serve_dir.iterdir()
|
|
363
|
+
if p.is_dir() and not p.is_symlink() and (p / "serve.yaml").is_file())
|
|
364
|
+
docker_names = []
|
|
365
|
+
if docker_dir.is_dir():
|
|
366
|
+
docker_names = sorted(
|
|
367
|
+
p.name for p in docker_dir.iterdir()
|
|
368
|
+
if p.is_dir() and ((p / "docker.yaml").is_file()
|
|
369
|
+
or (p / "wrapper.conf").is_file()))
|
|
370
|
+
has_root_docker = (root / "docker.yaml").is_file()
|
|
371
|
+
if not serve_names and not docker_names and not has_root_docker:
|
|
372
|
+
return []
|
|
373
|
+
|
|
374
|
+
notes = []
|
|
375
|
+
for name in serve_names: # host profiles first: name wins
|
|
376
|
+
_adopt_serve_profile(root, name, notes)
|
|
377
|
+
for name in docker_names:
|
|
378
|
+
_adopt_docker_profile(root, name, notes)
|
|
379
|
+
if has_root_docker:
|
|
380
|
+
_adopt_root_docker(root, notes)
|
|
381
|
+
|
|
382
|
+
if notes:
|
|
383
|
+
if announce is None:
|
|
384
|
+
from painapple_code.cli.ui import info
|
|
385
|
+
announce = info
|
|
386
|
+
announce("Migrating to the unified profile store "
|
|
387
|
+
f"({profiles_root(root)}):")
|
|
388
|
+
for note in notes:
|
|
389
|
+
announce(f" {note}")
|
|
390
|
+
return notes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Argument parser for ``painapple serve`` — the single source of truth.
|
|
2
|
+
|
|
3
|
+
IMPORT-LIGHT ZONE: this module is the fast path for ``-v`` /
|
|
4
|
+
``serve --help`` / argparse errors, which ``cli.main()`` resolves
|
|
5
|
+
*before* paying the ~300ms ``painapple_code.server`` import. It may
|
|
6
|
+
import ``argparse``, ``os``, ``sys``, and ``painapple_code.__version__``
|
|
7
|
+
— NOTHING else. No server, no fastapi, no routes, no yaml.
|
|
8
|
+
``tests/test_cli_startup.py`` asserts fastapi stays unimported.
|
|
9
|
+
|
|
10
|
+
Both callers build from here, so help/error output and parsing are
|
|
11
|
+
byte-identical by construction:
|
|
12
|
+
|
|
13
|
+
- ``cli.main()`` — the fast gate (parse, then discard)
|
|
14
|
+
- ``server.main()`` — the real boot (re-parse, ~1ms, keeps its
|
|
15
|
+
signature and late ``parser.error()`` calls unchanged)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from painapple_code import __version__
|
|
23
|
+
|
|
24
|
+
#: Default bind port. Shared with ``server.resolve_allowed_origins()``, which
|
|
25
|
+
#: derives its no-config trusted-origin fallback from it — keep it a constant so
|
|
26
|
+
#: the two can't drift (they did: a hardcoded origin list outlived a port
|
|
27
|
+
#: renumber and shipped a stale port as a trusted default for months).
|
|
28
|
+
DEFAULT_PORT = 8765
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _subcommands_epilog():
|
|
32
|
+
"""Epilog for `serve --help` pointing back at the command overview
|
|
33
|
+
and the other subcommands (the curated front door is `painapple
|
|
34
|
+
help`; this page is deliberately just the full serve-flag dump).
|
|
35
|
+
|
|
36
|
+
argparse colorizes its own generated help (usage, option strings,
|
|
37
|
+
section headings) on Python 3.14+, but passes this free-form epilog
|
|
38
|
+
through verbatim — so it renders plain while everything above it is
|
|
39
|
+
colored. Match argparse's own decision: colorize the block only when
|
|
40
|
+
argparse would (3.14+, a TTY, and NO_COLOR unset), otherwise emit
|
|
41
|
+
plain text. ANSI codes have zero display width, so column alignment
|
|
42
|
+
is unaffected.
|
|
43
|
+
"""
|
|
44
|
+
color = (
|
|
45
|
+
sys.version_info >= (3, 14)
|
|
46
|
+
and sys.stdout.isatty()
|
|
47
|
+
and not os.environ.get("NO_COLOR")
|
|
48
|
+
)
|
|
49
|
+
b = "\033[1m" if color else "" # bold heading
|
|
50
|
+
g = "\033[32m" if color else "" # green subcommand
|
|
51
|
+
d = "\033[2m" if color else "" # dim description
|
|
52
|
+
r = "\033[0m" if color else ""
|
|
53
|
+
return "\n".join((
|
|
54
|
+
f"{b}profiles{r}:",
|
|
55
|
+
f" painapple {g}--profile NAME{r} {d}run a named deployment in the foreground (own port/data/sessions){r}",
|
|
56
|
+
f" painapple {g}setup NAME{r} {d}create/edit that profile (host or docker mode){r}",
|
|
57
|
+
"",
|
|
58
|
+
f"{b}more{r}:",
|
|
59
|
+
f" painapple {g}help{r} {d}command overview (setup · list · start/stop/restart · status · …){r}",
|
|
60
|
+
f" painapple {g}setup{r} {d}save global defaults for these flags (~/.painapple-code/serve.yaml){r}",
|
|
61
|
+
f" painapple {g}list{r} {d}every instance on this machine (profiles + running processes){r}",
|
|
62
|
+
))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_parser():
|
|
66
|
+
"""The full ``painapple serve`` flag parser."""
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
prog="painapple", description="pAInapple Code Server",
|
|
69
|
+
usage="painapple [serve] [--port N] [--workspace PATH] [flags…]",
|
|
70
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
71
|
+
epilog=_subcommands_epilog())
|
|
72
|
+
parser.add_argument("-v", "--version", action="version",
|
|
73
|
+
version=f"painapple {__version__}")
|
|
74
|
+
|
|
75
|
+
core = parser.add_argument_group(
|
|
76
|
+
"server", "where it listens and what it works on")
|
|
77
|
+
core.add_argument("--host", default="127.0.0.1",
|
|
78
|
+
help="Host interface to bind (default 127.0.0.1 — this machine "
|
|
79
|
+
"only; 0.0.0.0 = every interface, reachable on your LAN)")
|
|
80
|
+
core.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to bind to")
|
|
81
|
+
core.add_argument("--workspace", "--cwd", dest="workspace", default=".",
|
|
82
|
+
help="Workspace root — the directory holding your projects. "
|
|
83
|
+
"You pick the actual project in-app from the welcome "
|
|
84
|
+
"screen, and each session's files/git/terminal follow "
|
|
85
|
+
"its own directory. This anchors the file explorer and "
|
|
86
|
+
"the welcome screen's project suggestions. Mapped to "
|
|
87
|
+
"/workspace inside Docker. (--cwd is an alias)")
|
|
88
|
+
core.add_argument("--instance-name", default=None,
|
|
89
|
+
help="Instance label for PWA icon and UI (e.g., DEV, STABLE)")
|
|
90
|
+
core.add_argument("--accent", default=None,
|
|
91
|
+
help="Accent color: preset (blue/green/red/orange/purple/cyan/gray/yellow/pink/teal/indigo/lime) or hex (#f87171)")
|
|
92
|
+
core.add_argument("--in-docker", action="store_true",
|
|
93
|
+
help="Run this same invocation inside a container instead "
|
|
94
|
+
"(Docker/Podman): the workspace (default: current dir) is "
|
|
95
|
+
"mounted into the prebuilt image, foreground, Ctrl-C stops "
|
|
96
|
+
"it. Runtime/image defaults come from `painapple setup`; "
|
|
97
|
+
"fetch the image once with `painapple pull`.")
|
|
98
|
+
|
|
99
|
+
net = parser.add_argument_group(
|
|
100
|
+
"network security", "TLS for non-loopback binds")
|
|
101
|
+
net.add_argument("--tls", choices=("auto", "on", "off"), default="auto",
|
|
102
|
+
help="TLS mode. 'auto' (default) enables TLS when binding to a non-loopback "
|
|
103
|
+
"host. 'on' forces TLS. 'off' disables it. Self-signed cert is "
|
|
104
|
+
"auto-generated; clients accept it without verification (TLS here "
|
|
105
|
+
"guards against passive snooping only).")
|
|
106
|
+
pw = net.add_mutually_exclusive_group()
|
|
107
|
+
pw.add_argument("--no-password", "--no-passwd", dest="no_password",
|
|
108
|
+
action="store_true",
|
|
109
|
+
help="Never print credentials to stdout: the startup box hides "
|
|
110
|
+
"the password and strips the ?tkn= from the login URL. "
|
|
111
|
+
"Retrieve them later with `painapple password` or from the "
|
|
112
|
+
"auth config file. Useful when the console is visible to "
|
|
113
|
+
"others (screen shares, recorded demos, shared logs). "
|
|
114
|
+
"This is already the default on non-loopback binds.")
|
|
115
|
+
pw.add_argument("--show-password", dest="show_password",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="Print credentials even on a non-loopback bind. By default "
|
|
118
|
+
"only loopback binds (127.0.0.1/::1/localhost) show the "
|
|
119
|
+
"password and ?tkn= login URL — a LAN/public server's "
|
|
120
|
+
"stdout tends to outlive the terminal (journald, docker "
|
|
121
|
+
"logs), so there they're hidden unless you opt in.")
|
|
122
|
+
net.add_argument("--tls-cert", default=None, metavar="PATH",
|
|
123
|
+
help="TLS cert path (default: <config-dir>/cert.pem, auto-generated)")
|
|
124
|
+
net.add_argument("--tls-key", default=None, metavar="PATH",
|
|
125
|
+
help="TLS key path (default: <config-dir>/key.pem, auto-generated)")
|
|
126
|
+
|
|
127
|
+
adv = parser.add_argument_group(
|
|
128
|
+
"advanced", "provider choice and opt-in extras")
|
|
129
|
+
adv.add_argument("--default-provider", default=None, metavar="NAME",
|
|
130
|
+
help="Provider for NEW sessions (existing sessions keep their "
|
|
131
|
+
"recorded provider). Overrides the `default_provider` "
|
|
132
|
+
"global-config key. E.g. 'claude-sdk' (Agent SDK driver — "
|
|
133
|
+
"interactive permission cards, live mode/model switching; "
|
|
134
|
+
"default) or 'claude' (line protocol).")
|
|
135
|
+
adv.add_argument("--public-origin", action="append", default=None, metavar="ORIGIN",
|
|
136
|
+
help="Trusted browser origin for a proxied deployment, e.g. "
|
|
137
|
+
"https://claude.example.com. Repeatable. Adds to the "
|
|
138
|
+
"HTTP/WebSocket Origin allowlist (and replaces the default, "
|
|
139
|
+
"which trusts only loopback on the bound port). Also "
|
|
140
|
+
"honoured via PAINAPPLE_ALLOWED_ORIGINS.")
|
|
141
|
+
adv.add_argument("--enable-renderers", action="store_true",
|
|
142
|
+
help="Enable server-side Vega-Lite/Excalidraw rendering (off by "
|
|
143
|
+
"default: model-authored specs are rendered via a Node "
|
|
144
|
+
"subprocess whose data loader can fetch external/file URLs — "
|
|
145
|
+
"an SSRF/local-file-read vector. Also honoured via the "
|
|
146
|
+
"PAINAPPLE_ENABLE_RENDERERS env var).")
|
|
147
|
+
|
|
148
|
+
tiers = parser.add_argument_group(
|
|
149
|
+
"multi-instance", "isolate state when several servers share one user "
|
|
150
|
+
"(each instance needs its own shadow DB — DuckDB is single-writer)")
|
|
151
|
+
tiers.add_argument("--shadow-db", default=None, metavar="PATH",
|
|
152
|
+
help="DuckDB path for shadow DB (default: ~/.painapple-code/shadow.duckdb)")
|
|
153
|
+
tiers.add_argument("--log-dir", default=None, metavar="PATH",
|
|
154
|
+
help="Log directory (default: ~/.painapple-code/logs/)")
|
|
155
|
+
tiers.add_argument("--state-suffix", default=None, metavar="SUFFIX",
|
|
156
|
+
help="Per-tier suffix for UI-state files (tab-state, shortcuts, "
|
|
157
|
+
"presets, favorites, global config) so co-located tiers don't "
|
|
158
|
+
"share them, e.g. 'dev' gives tab-state-dev.json (a leading "
|
|
159
|
+
"'-' is added automatically). Project and session history "
|
|
160
|
+
"stays shared. Default: none (stable tier).")
|
|
161
|
+
tiers.add_argument("--auth-config-file", default=None, metavar="PATH",
|
|
162
|
+
help="Config file path (default: ~/.config/painapple-code/config.yaml)")
|
|
163
|
+
return parser
|