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.
Files changed (436) hide show
  1. painapple_code/__init__.py +30 -0
  2. painapple_code/__main__.py +18 -0
  3. painapple_code/_version.py +24 -0
  4. painapple_code/auth_middleware.py +930 -0
  5. painapple_code/cli/__init__.py +204 -0
  6. painapple_code/cli/deploy/__init__.py +15 -0
  7. painapple_code/cli/deploy/claude_seed.py +41 -0
  8. painapple_code/cli/deploy/config.py +260 -0
  9. painapple_code/cli/deploy/container.py +715 -0
  10. painapple_code/cli/deploy/launch.py +163 -0
  11. painapple_code/cli/deploy/runtime.py +223 -0
  12. painapple_code/cli/lifecycle_cmd.py +443 -0
  13. painapple_code/cli/list_cmd.py +572 -0
  14. painapple_code/cli/manage_cmd.py +670 -0
  15. painapple_code/cli/netinfo.py +125 -0
  16. painapple_code/cli/profiles.py +390 -0
  17. painapple_code/cli/serve_args.py +163 -0
  18. painapple_code/cli/serve_config.py +256 -0
  19. painapple_code/cli/setup_cmd.py +664 -0
  20. painapple_code/cli/ui.py +438 -0
  21. painapple_code/cost_analytics.py +879 -0
  22. painapple_code/data/models.defaults.yaml +18 -0
  23. painapple_code/data/models.yaml +18 -0
  24. painapple_code/data/presets.defaults.json +32 -0
  25. painapple_code/data/strings.yaml +2269 -0
  26. painapple_code/helpers.py +406 -0
  27. painapple_code/paths.py +1514 -0
  28. painapple_code/prompt_explorer.py +984 -0
  29. painapple_code/providers/__init__.py +224 -0
  30. painapple_code/providers/base.py +710 -0
  31. painapple_code/providers/claude/__init__.py +74 -0
  32. painapple_code/providers/claude/capabilities.py +218 -0
  33. painapple_code/providers/claude/errors.py +79 -0
  34. painapple_code/providers/claude/launch.py +50 -0
  35. painapple_code/providers/claude/summary.py +89 -0
  36. painapple_code/providers/claude/translate.py +74 -0
  37. painapple_code/providers/claude_sdk/__init__.py +139 -0
  38. painapple_code/providers/claude_sdk/driver.py +412 -0
  39. painapple_code/providers/codex/__init__.py +87 -0
  40. painapple_code/providers/codex/capabilities.py +321 -0
  41. painapple_code/providers/codex/errors.py +70 -0
  42. painapple_code/providers/codex/launch.py +142 -0
  43. painapple_code/providers/codex/summary.py +196 -0
  44. painapple_code/providers/codex/translate.py +204 -0
  45. painapple_code/providers/codex_app_server/__init__.py +100 -0
  46. painapple_code/providers/codex_app_server/capabilities.py +41 -0
  47. painapple_code/providers/codex_app_server/launch.py +202 -0
  48. painapple_code/providers/codex_app_server/summary.py +291 -0
  49. painapple_code/providers/codex_app_server/translate.py +263 -0
  50. painapple_code/providers/codex_app_server/transport.py +238 -0
  51. painapple_code/routes/__init__.py +0 -0
  52. painapple_code/routes/api_agents.py +441 -0
  53. painapple_code/routes/api_app.py +368 -0
  54. painapple_code/routes/api_app_commit_sections.py +127 -0
  55. painapple_code/routes/api_app_config.py +997 -0
  56. painapple_code/routes/api_app_session_prefs.py +217 -0
  57. painapple_code/routes/api_browser.py +690 -0
  58. painapple_code/routes/api_commands.py +374 -0
  59. painapple_code/routes/api_costs.py +128 -0
  60. painapple_code/routes/api_drafts.py +139 -0
  61. painapple_code/routes/api_exec.py +149 -0
  62. painapple_code/routes/api_files.py +542 -0
  63. painapple_code/routes/api_git.py +657 -0
  64. painapple_code/routes/api_logs.py +649 -0
  65. painapple_code/routes/api_plugins.py +275 -0
  66. painapple_code/routes/api_project_config.py +418 -0
  67. painapple_code/routes/api_prompts.py +231 -0
  68. painapple_code/routes/api_providers.py +53 -0
  69. painapple_code/routes/api_search.py +411 -0
  70. painapple_code/routes/api_session_stash.py +234 -0
  71. painapple_code/routes/api_session_welcome.py +151 -0
  72. painapple_code/routes/api_sessions.py +742 -0
  73. painapple_code/routes/api_shadow.py +323 -0
  74. painapple_code/routes/api_shadow_db.py +512 -0
  75. painapple_code/routes/api_shadow_files.py +332 -0
  76. painapple_code/routes/api_shadow_search.py +476 -0
  77. painapple_code/routes/api_skills.py +610 -0
  78. painapple_code/routes/api_tasks.py +147 -0
  79. painapple_code/routes/api_terminal.py +540 -0
  80. painapple_code/routes/api_upload.py +293 -0
  81. painapple_code/routes/api_viewer.py +229 -0
  82. painapple_code/routes/dependencies.py +109 -0
  83. painapple_code/routes/ws_chat.py +837 -0
  84. painapple_code/server.py +2208 -0
  85. painapple_code/server_logging.py +263 -0
  86. painapple_code/services/__init__.py +0 -0
  87. painapple_code/services/agent_session.py +2738 -0
  88. painapple_code/session_store.py +253 -0
  89. painapple_code/session_store_core.py +879 -0
  90. painapple_code/shadow_db.py +830 -0
  91. painapple_code/shadow_db_plans.py +369 -0
  92. painapple_code/shadow_db_queries.py +595 -0
  93. painapple_code/shadow_db_schema.py +676 -0
  94. painapple_code/shadow_git.py +1296 -0
  95. painapple_code/shadow_git_frontmatter.py +314 -0
  96. painapple_code/shadow_git_sections.py +520 -0
  97. painapple_code/shadow_git_summary.py +462 -0
  98. painapple_code/shadow_parser.py +560 -0
  99. painapple_code/static/css/00-variables.css +276 -0
  100. painapple_code/static/css/01-base.css +245 -0
  101. painapple_code/static/css/02-tooltips.css +36 -0
  102. painapple_code/static/css/09-left-rail.css +448 -0
  103. painapple_code/static/css/10-header.css +777 -0
  104. painapple_code/static/css/11-connection-bar.css +120 -0
  105. painapple_code/static/css/20-messages.css +750 -0
  106. painapple_code/static/css/21-tool-blocks.css +2340 -0
  107. painapple_code/static/css/22-edit-diff.css +619 -0
  108. painapple_code/static/css/23-thinking.css +1584 -0
  109. painapple_code/static/css/24-background-tasks.css +359 -0
  110. painapple_code/static/css/25-context-block.css +252 -0
  111. painapple_code/static/css/26-session-setup.css +144 -0
  112. painapple_code/static/css/30-markdown.css +854 -0
  113. painapple_code/static/css/40-input.css +2418 -0
  114. painapple_code/static/css/41-autocomplete.css +127 -0
  115. painapple_code/static/css/42-activity-strip.css +97 -0
  116. painapple_code/static/css/43-file-autocomplete.css +121 -0
  117. painapple_code/static/css/44-snippets-autocomplete.css +224 -0
  118. painapple_code/static/css/45-selection-bar.css +433 -0
  119. painapple_code/static/css/46-keyboard-bar.css +119 -0
  120. painapple_code/static/css/47-keyboard-longpress.css +116 -0
  121. painapple_code/static/css/47-token-profile.css +106 -0
  122. painapple_code/static/css/50-welcome.css +3095 -0
  123. painapple_code/static/css/51-modals.css +301 -0
  124. painapple_code/static/css/52-utilities.css +77 -0
  125. painapple_code/static/css/53-image-upload.css +456 -0
  126. painapple_code/static/css/54-todo-list.css +102 -0
  127. painapple_code/static/css/55-file-upload.css +113 -0
  128. painapple_code/static/css/56-chat-navigator.css +218 -0
  129. painapple_code/static/css/56-permission-card.css +332 -0
  130. painapple_code/static/css/56-plan-approval.css +204 -0
  131. painapple_code/static/css/56-question-form.css +602 -0
  132. painapple_code/static/css/57-swipe-indicator.css +95 -0
  133. painapple_code/static/css/58-session-families.css +627 -0
  134. painapple_code/static/css/59-about.css +196 -0
  135. painapple_code/static/css/61-log-explorer.css +723 -0
  136. painapple_code/static/css/62-json-tree.css +489 -0
  137. painapple_code/static/css/62-skills-widget.css +650 -0
  138. painapple_code/static/css/63-commands-widget.css +30 -0
  139. painapple_code/static/css/63-plugins-widget.css +174 -0
  140. painapple_code/static/css/63-terminal.css +516 -0
  141. painapple_code/static/css/64-search-files-widget.css +448 -0
  142. painapple_code/static/css/65-snippets-widget.css +426 -0
  143. painapple_code/static/css/66-active-sessions.css +979 -0
  144. painapple_code/static/css/66-agents-widget.css +265 -0
  145. painapple_code/static/css/66-file-explorer-widget.css +855 -0
  146. painapple_code/static/css/66-git-panel.css +791 -0
  147. painapple_code/static/css/66-history-explorer.css +1496 -0
  148. painapple_code/static/css/67-diff-viewer.css +602 -0
  149. painapple_code/static/css/67-editor-view.css +748 -0
  150. painapple_code/static/css/67-file-preview-widget.css +1873 -0
  151. painapple_code/static/css/68-browser-widget.css +337 -0
  152. painapple_code/static/css/68-compare-wizard.css +355 -0
  153. painapple_code/static/css/68-csv-preview.css +106 -0
  154. painapple_code/static/css/68-diff-preview.css +103 -0
  155. painapple_code/static/css/68-helpers-install-widget.css +491 -0
  156. painapple_code/static/css/68-image-annotate.css +346 -0
  157. painapple_code/static/css/68-terminal-view.css +98 -0
  158. painapple_code/static/css/68-uploads-widget.css +229 -0
  159. painapple_code/static/css/69-cost-analytics.css +537 -0
  160. painapple_code/static/css/70-config-panel.css +3736 -0
  161. painapple_code/static/css/70-excalidraw.css +136 -0
  162. painapple_code/static/css/70-prompt-explorer.css +890 -0
  163. painapple_code/static/css/71-chart.css +111 -0
  164. painapple_code/static/css/72-discussion-sidebar.css +678 -0
  165. painapple_code/static/css/74-debug-logs.css +656 -0
  166. painapple_code/static/css/80-open-dialog.css +146 -0
  167. painapple_code/static/css/80-quick-switcher.css +383 -0
  168. painapple_code/static/css/80-widget-system.css +1763 -0
  169. painapple_code/static/css/81-quick-actions.css +1020 -0
  170. painapple_code/static/css/82-context-popover.css +1233 -0
  171. painapple_code/static/css/83-context-menu.css +339 -0
  172. painapple_code/static/css/85-lazy-loading.css +102 -0
  173. painapple_code/static/css/86-zen-mode.css +708 -0
  174. painapple_code/static/css/87-grid-switcher.css +346 -0
  175. painapple_code/static/css/login.css +198 -0
  176. painapple_code/static/dist/app.bundle.js +7177 -0
  177. painapple_code/static/feature-triage.html +446 -0
  178. painapple_code/static/icons/apple-touch-icon.png +0 -0
  179. painapple_code/static/icons/favicon-32.png +0 -0
  180. painapple_code/static/icons/favicon.ico +0 -0
  181. painapple_code/static/icons/icon-128.png +0 -0
  182. painapple_code/static/icons/icon-144.png +0 -0
  183. painapple_code/static/icons/icon-152.png +0 -0
  184. painapple_code/static/icons/icon-180.png +0 -0
  185. painapple_code/static/icons/icon-192.png +0 -0
  186. painapple_code/static/icons/icon-384.png +0 -0
  187. painapple_code/static/icons/icon-512.png +0 -0
  188. painapple_code/static/icons/icon-72.png +0 -0
  189. painapple_code/static/icons/icon-96.png +0 -0
  190. painapple_code/static/js/action-delegate.js +187 -0
  191. painapple_code/static/js/activity-strip.js +193 -0
  192. painapple_code/static/js/app/context-menus.js +619 -0
  193. painapple_code/static/js/app/file-preview.js +163 -0
  194. painapple_code/static/js/app/init-ui.js +574 -0
  195. painapple_code/static/js/app/input-events.js +561 -0
  196. painapple_code/static/js/app/input-resize.js +313 -0
  197. painapple_code/static/js/app/message-actions.js +419 -0
  198. painapple_code/static/js/app/panels.js +210 -0
  199. painapple_code/static/js/app/rendering-delegators.js +246 -0
  200. painapple_code/static/js/app/server-loading.js +365 -0
  201. painapple_code/static/js/app/session-ops.js +287 -0
  202. painapple_code/static/js/app/session-switch.js +443 -0
  203. painapple_code/static/js/app/settings-helpers.js +280 -0
  204. painapple_code/static/js/app/startup-welcome.js +290 -0
  205. painapple_code/static/js/app-context.js +157 -0
  206. painapple_code/static/js/app.js +2134 -0
  207. painapple_code/static/js/auth-fetch.js +46 -0
  208. painapple_code/static/js/background-tasks.js +274 -0
  209. painapple_code/static/js/boot.js +73 -0
  210. painapple_code/static/js/caret-position.js +171 -0
  211. painapple_code/static/js/chat-navigator.js +710 -0
  212. painapple_code/static/js/chat-search.js +323 -0
  213. painapple_code/static/js/command-executor.js +176 -0
  214. painapple_code/static/js/command-store.js +497 -0
  215. painapple_code/static/js/components.js +1414 -0
  216. painapple_code/static/js/config.js +218 -0
  217. painapple_code/static/js/context-menu.js +740 -0
  218. painapple_code/static/js/controllers/chat-controller.js +4252 -0
  219. painapple_code/static/js/controllers/dialog-controller.js +217 -0
  220. painapple_code/static/js/controllers/tab-controller.js +1828 -0
  221. painapple_code/static/js/controllers/thinking-controller.js +915 -0
  222. painapple_code/static/js/diff-utils.js +486 -0
  223. painapple_code/static/js/drafts-pill.js +95 -0
  224. painapple_code/static/js/editor-view.js +815 -0
  225. painapple_code/static/js/effort-settings.js +430 -0
  226. painapple_code/static/js/feature-triage.js +811 -0
  227. painapple_code/static/js/file-autocomplete.js +516 -0
  228. painapple_code/static/js/file-tabs.js +123 -0
  229. painapple_code/static/js/gestures.js +448 -0
  230. painapple_code/static/js/grid-switcher.js +334 -0
  231. painapple_code/static/js/input-handler.js +1472 -0
  232. painapple_code/static/js/keyboard-bar.js +495 -0
  233. painapple_code/static/js/layout-switcher.js +109 -0
  234. painapple_code/static/js/linkify-utils.js +200 -0
  235. painapple_code/static/js/login.js +141 -0
  236. painapple_code/static/js/open-dialog.js +879 -0
  237. painapple_code/static/js/orphan-terminals.js +373 -0
  238. painapple_code/static/js/path-utils.js +179 -0
  239. painapple_code/static/js/perf-marks.js +102 -0
  240. painapple_code/static/js/permission-settings.js +475 -0
  241. painapple_code/static/js/pointer-input.js +59 -0
  242. painapple_code/static/js/preview/json-tree-menu.js +226 -0
  243. painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
  244. painapple_code/static/js/preview/json-tree.js +457 -0
  245. painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
  246. painapple_code/static/js/preview/preview-edit.js +367 -0
  247. painapple_code/static/js/preview/preview-events.js +361 -0
  248. painapple_code/static/js/preview/preview-history.js +567 -0
  249. painapple_code/static/js/preview/preview-inline-edit.js +869 -0
  250. painapple_code/static/js/preview/preview-poll.js +108 -0
  251. painapple_code/static/js/preview/preview-render.js +572 -0
  252. painapple_code/static/js/preview/preview-search.js +421 -0
  253. painapple_code/static/js/preview/preview-state.js +256 -0
  254. painapple_code/static/js/preview/preview-utils.js +251 -0
  255. painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
  256. painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
  257. painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
  258. painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
  259. painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
  260. painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
  261. painapple_code/static/js/preview-plugins/index.js +34 -0
  262. painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
  263. painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
  264. painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
  265. painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
  266. painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
  267. painapple_code/static/js/project-color-picker.js +110 -0
  268. painapple_code/static/js/project-colors.js +163 -0
  269. painapple_code/static/js/prompt-favorites.js +187 -0
  270. painapple_code/static/js/quick-actions-menu.js +1736 -0
  271. painapple_code/static/js/quick-actions-registry.js +1239 -0
  272. painapple_code/static/js/quick-switcher/controller.js +294 -0
  273. painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
  274. painapple_code/static/js/quick-switcher/index.js +30 -0
  275. painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
  276. painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
  277. painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
  278. painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
  279. painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
  280. painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
  281. painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
  282. painapple_code/static/js/quick-switcher/registry.js +69 -0
  283. painapple_code/static/js/quick-switcher/ui/item.js +54 -0
  284. painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
  285. painapple_code/static/js/recent-opens.js +40 -0
  286. painapple_code/static/js/scroll-manager.js +530 -0
  287. painapple_code/static/js/scroll-state-machine.js +359 -0
  288. painapple_code/static/js/selection/action-bar.js +793 -0
  289. painapple_code/static/js/selection/index.js +15 -0
  290. painapple_code/static/js/selection/selection-handler.js +877 -0
  291. painapple_code/static/js/selection/state.js +181 -0
  292. painapple_code/static/js/session/agent-progress.js +202 -0
  293. painapple_code/static/js/session/handle-agent-message.js +481 -0
  294. painapple_code/static/js/session/handle-message.js +550 -0
  295. painapple_code/static/js/session/interactive.js +587 -0
  296. painapple_code/static/js/session/message-store.js +271 -0
  297. painapple_code/static/js/session/messages.js +253 -0
  298. painapple_code/static/js/session/persistence.js +179 -0
  299. painapple_code/static/js/session/restore.js +136 -0
  300. painapple_code/static/js/session/sync.js +333 -0
  301. painapple_code/static/js/session-container-pool.js +371 -0
  302. painapple_code/static/js/session-setup-panel.js +274 -0
  303. painapple_code/static/js/session.js +1047 -0
  304. painapple_code/static/js/shortcut-hints.js +272 -0
  305. painapple_code/static/js/shortcuts.js +1146 -0
  306. painapple_code/static/js/skills-autocomplete.js +262 -0
  307. painapple_code/static/js/snippets-autocomplete.js +738 -0
  308. painapple_code/static/js/stash-refs-view.js +83 -0
  309. painapple_code/static/js/stash-ui.js +768 -0
  310. painapple_code/static/js/stash.js +785 -0
  311. painapple_code/static/js/status-bar.js +1184 -0
  312. painapple_code/static/js/token-profile.js +274 -0
  313. painapple_code/static/js/tool-renderer-blocks.js +1626 -0
  314. painapple_code/static/js/tool-renderer-thinking.js +813 -0
  315. painapple_code/static/js/tool-renderer.js +1500 -0
  316. painapple_code/static/js/tooltips.js +197 -0
  317. painapple_code/static/js/upload-manager.js +1019 -0
  318. painapple_code/static/js/utils.js +521 -0
  319. painapple_code/static/js/welcome/api.js +363 -0
  320. painapple_code/static/js/welcome/cards.js +626 -0
  321. painapple_code/static/js/welcome/context-menu.js +585 -0
  322. painapple_code/static/js/welcome/families.js +801 -0
  323. painapple_code/static/js/welcome/preview.js +234 -0
  324. painapple_code/static/js/welcome/state.js +129 -0
  325. painapple_code/static/js/welcome.js +1874 -0
  326. painapple_code/static/js/widget-system/base-widget.js +717 -0
  327. painapple_code/static/js/widget-system/device-manager.js +178 -0
  328. painapple_code/static/js/widget-system/event-bus.js +152 -0
  329. painapple_code/static/js/widget-system/icons.js +120 -0
  330. painapple_code/static/js/widget-system/index.js +49 -0
  331. painapple_code/static/js/widget-system/init.js +54 -0
  332. painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
  333. painapple_code/static/js/widget-system/types/floating.js +915 -0
  334. painapple_code/static/js/widget-system/types/index.js +40 -0
  335. painapple_code/static/js/widget-system/types/modal.js +147 -0
  336. painapple_code/static/js/widget-system/types/sidebar.js +158 -0
  337. painapple_code/static/js/widget-system/types/tab.js +146 -0
  338. painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
  339. painapple_code/static/js/widget-system/widget-manager.js +1187 -0
  340. painapple_code/static/js/widgets/about-widget.js +260 -0
  341. painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
  342. painapple_code/static/js/widgets/agents-widget.js +897 -0
  343. painapple_code/static/js/widgets/browser-widget.js +579 -0
  344. painapple_code/static/js/widgets/commands-widget.js +694 -0
  345. painapple_code/static/js/widgets/config/commit-sections.js +449 -0
  346. painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
  347. painapple_code/static/js/widgets/config/gestures.js +161 -0
  348. painapple_code/static/js/widgets/config/models-tab.js +988 -0
  349. painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
  350. painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
  351. painapple_code/static/js/widgets/config/state.js +517 -0
  352. painapple_code/static/js/widgets/config/system-controls.js +90 -0
  353. painapple_code/static/js/widgets/config-widget.js +1535 -0
  354. painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
  355. painapple_code/static/js/widgets/debug-widget.js +693 -0
  356. painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
  357. painapple_code/static/js/widgets/discussion-widget.js +1107 -0
  358. painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
  359. painapple_code/static/js/widgets/file-preview-widget.js +861 -0
  360. painapple_code/static/js/widgets/git-widget.js +794 -0
  361. painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
  362. painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
  363. painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
  364. painapple_code/static/js/widgets/image-preview-widget.js +692 -0
  365. painapple_code/static/js/widgets/index.js +176 -0
  366. painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
  367. painapple_code/static/js/widgets/plugins-widget.js +305 -0
  368. painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
  369. painapple_code/static/js/widgets/search-files-widget.js +529 -0
  370. painapple_code/static/js/widgets/skills-widget.js +843 -0
  371. painapple_code/static/js/widgets/snippets-widget.js +479 -0
  372. painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
  373. painapple_code/static/js/widgets/tasks-widget.js +379 -0
  374. painapple_code/static/js/widgets/terminal/connection.js +283 -0
  375. painapple_code/static/js/widgets/terminal/gestures.js +357 -0
  376. painapple_code/static/js/widgets/terminal/init.js +642 -0
  377. painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
  378. painapple_code/static/js/widgets/terminal/osc52.js +213 -0
  379. painapple_code/static/js/widgets/terminal/render.js +159 -0
  380. painapple_code/static/js/widgets/terminal/size.js +54 -0
  381. painapple_code/static/js/widgets/terminal/state.js +211 -0
  382. painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
  383. painapple_code/static/js/widgets/terminal-widget.js +793 -0
  384. painapple_code/static/js/widgets/uploads-widget.js +461 -0
  385. painapple_code/static/js/widgets/zen-widget.js +1001 -0
  386. painapple_code/static/login.html +51 -0
  387. painapple_code/static/manifest.json +73 -0
  388. painapple_code/static/sw.js +293 -0
  389. painapple_code/static/vendor/LICENSES.txt +769 -0
  390. painapple_code/static/vendor/README.md +103 -0
  391. painapple_code/static/vendor/codemirror.js +35 -0
  392. painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
  393. painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
  394. painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
  395. painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
  396. painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
  397. painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
  398. painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
  399. painapple_code/static/vendor/highlight.min.js +1213 -0
  400. painapple_code/static/vendor/marked.min.js +6 -0
  401. painapple_code/static/vendor/purify.min.js +3 -0
  402. painapple_code/static/vendor/xterm-addon-fit.js +2 -0
  403. painapple_code/static/vendor/xterm.css +209 -0
  404. painapple_code/static/vendor/xterm.js +2 -0
  405. painapple_code/static/web-client.html +635 -0
  406. painapple_code/subprocess_registry.py +535 -0
  407. painapple_code/tls_cert.py +79 -0
  408. painapple_code/tools/agents/shadow-git-helper.md +400 -0
  409. painapple_code/tools/excalidraw-to-svg.js +143 -0
  410. painapple_code/tools/install-helpers.sh +148 -0
  411. painapple_code/tools/shadow-git +320 -0
  412. painapple_code/tools/shadow-query +120 -0
  413. painapple_code/tools/vegalite-to-svg.js +79 -0
  414. painapple_code/turn_query.py +766 -0
  415. painapple_code/turn_tracker.py +221 -0
  416. painapple_code/utils/__init__.py +0 -0
  417. painapple_code/utils/agent_cli.py +306 -0
  418. painapple_code/utils/chart.py +103 -0
  419. painapple_code/utils/excalidraw.py +107 -0
  420. painapple_code/utils/file_lock.py +162 -0
  421. painapple_code/utils/file_paths.py +648 -0
  422. painapple_code/utils/generate_icons.py +251 -0
  423. painapple_code/utils/proc.py +205 -0
  424. painapple_code/utils/pty_backend.py +59 -0
  425. painapple_code/utils/pty_posix.py +160 -0
  426. painapple_code/utils/pty_windows.py +245 -0
  427. painapple_code/utils/token_profiles.py +91 -0
  428. painapple_code/viewer_templates.py +657 -0
  429. painapple_code/welcome_search.py +879 -0
  430. painapple_code-1.0.0.dist-info/METADATA +292 -0
  431. painapple_code-1.0.0.dist-info/RECORD +436 -0
  432. painapple_code-1.0.0.dist-info/WHEEL +5 -0
  433. painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
  434. painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
  435. painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
  436. 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