sshler 1.0.2__tar.gz → 1.0.4__tar.gz

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 (294) hide show
  1. {sshler-1.0.2/sshler.egg-info → sshler-1.0.4}/PKG-INFO +85 -5
  2. {sshler-1.0.2 → sshler-1.0.4}/README.md +83 -4
  3. {sshler-1.0.2 → sshler-1.0.4}/pyproject.toml +14 -2
  4. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/__init__.py +8 -2
  5. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/archive.py +54 -32
  6. sshler-1.0.4/sshler/api/artifacts.py +516 -0
  7. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/auth.py +25 -13
  8. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/batch.py +43 -7
  9. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/boxes.py +60 -38
  10. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/claude_sessions.py +16 -6
  11. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/config.py +5 -0
  12. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/dependencies.py +74 -2
  13. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/diff.py +6 -2
  14. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/excel.py +11 -4
  15. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/files.py +481 -190
  16. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/git.py +47 -20
  17. sshler-1.0.4/sshler/api/grep.py +386 -0
  18. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/helpers.py +6 -4
  19. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/models.py +5 -1
  20. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/rate_limiting.py +5 -2
  21. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/recovery.py +17 -5
  22. sshler-1.0.4/sshler/api/search.py +401 -0
  23. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/sessions.py +103 -33
  24. sshler-1.0.4/sshler/api/stats_stream.py +176 -0
  25. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/transfer.py +3 -1
  26. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/tunnels.py +12 -0
  27. sshler-1.0.4/sshler/artifacts.py +492 -0
  28. {sshler-1.0.2 → sshler-1.0.4}/sshler/auth.py +1 -2
  29. {sshler-1.0.2 → sshler-1.0.4}/sshler/claude_sessions.py +2 -2
  30. {sshler-1.0.2 → sshler-1.0.4}/sshler/cli.py +472 -90
  31. {sshler-1.0.2 → sshler-1.0.4}/sshler/config.py +6 -2
  32. {sshler-1.0.2 → sshler-1.0.4}/sshler/config_cache.py +0 -1
  33. {sshler-1.0.2 → sshler-1.0.4}/sshler/pdf.py +4 -1
  34. {sshler-1.0.2 → sshler-1.0.4}/sshler/rate_limit.py +17 -5
  35. {sshler-1.0.2 → sshler-1.0.4}/sshler/session.py +23 -9
  36. {sshler-1.0.2 → sshler-1.0.4}/sshler/settings.py +14 -2
  37. {sshler-1.0.2 → sshler-1.0.4}/sshler/snapshot.py +56 -59
  38. {sshler-1.0.2 → sshler-1.0.4}/sshler/spa.py +2 -4
  39. {sshler-1.0.2 → sshler-1.0.4}/sshler/ssh.py +4 -2
  40. sshler-1.0.4/sshler/ssh_pool.py +609 -0
  41. {sshler-1.0.2 → sshler-1.0.4}/sshler/state.py +471 -4
  42. sshler-1.0.2/sshler/static/dist/assets/Add-DAOjsezW.js → sshler-1.0.4/sshler/static/dist/assets/Add-7ishajWp.js +1 -1
  43. sshler-1.0.2/sshler/static/dist/assets/Alert-CSag0xgj.js → sshler-1.0.4/sshler/static/dist/assets/Alert-BEFpRXXN.js +2 -2
  44. sshler-1.0.4/sshler/static/dist/assets/ArtifactsView-BTz2yI5O.js +1 -0
  45. sshler-1.0.4/sshler/static/dist/assets/ArtifactsView-DMnfzE9w.css +1 -0
  46. sshler-1.0.2/sshler/static/dist/assets/BoxesView-DRK-moOl.js → sshler-1.0.4/sshler/static/dist/assets/BoxesView-CTFAXFZS.js +1 -1
  47. sshler-1.0.4/sshler/static/dist/assets/ClaudeSessionsView-BId4dxhT.js +137 -0
  48. sshler-1.0.4/sshler/static/dist/assets/ClaudeSessionsView-BcS0-CLq.css +1 -0
  49. sshler-1.0.2/sshler/static/dist/assets/CommanderView-DWT0Nvkn.css → sshler-1.0.4/sshler/static/dist/assets/CommanderView-Bvzg3bq2.css +1 -1
  50. sshler-1.0.4/sshler/static/dist/assets/CommanderView-DDAlB9za.js +1 -0
  51. sshler-1.0.2/sshler/static/dist/assets/DiffView-D1B9VV_z.css → sshler-1.0.4/sshler/static/dist/assets/DiffView-9QDQycKp.css +1 -1
  52. sshler-1.0.4/sshler/static/dist/assets/DiffView-Cp_wf2aN.js +10 -0
  53. sshler-1.0.2/sshler/static/dist/assets/DiffViewer-BKOI30kQ.js → sshler-1.0.4/sshler/static/dist/assets/DiffViewer-DcBoOSHq.js +1 -1
  54. sshler-1.0.4/sshler/static/dist/assets/DirectoryPickerModal-B1GuKICB.css +1 -0
  55. sshler-1.0.4/sshler/static/dist/assets/DirectoryPickerModal-CFm5mhtT.js +79 -0
  56. sshler-1.0.4/sshler/static/dist/assets/FileEditorModal-D7TNr5fU.css +1 -0
  57. sshler-1.0.4/sshler/static/dist/assets/FileEditorModal-DlTADbFx.js +352 -0
  58. sshler-1.0.4/sshler/static/dist/assets/FilesView-B9vdgK9X.css +1 -0
  59. sshler-1.0.4/sshler/static/dist/assets/FilesView-DIILqN2k.js +751 -0
  60. sshler-1.0.4/sshler/static/dist/assets/GitBadge-YKCC5hhN.js +1 -0
  61. sshler-1.0.2/sshler/static/dist/assets/Grid-86dNpU6i.js → sshler-1.0.4/sshler/static/dist/assets/Grid-Dc94lNab.js +1 -1
  62. sshler-1.0.2/sshler/static/dist/assets/InputNumber-9ldqAl1a.js → sshler-1.0.4/sshler/static/dist/assets/InputNumber-Cr0522nD.js +4 -4
  63. sshler-1.0.2/sshler/static/dist/assets/LoginView-DezvuYtZ.js → sshler-1.0.4/sshler/static/dist/assets/LoginView-B8rFl-TN.js +11 -11
  64. sshler-1.0.4/sshler/static/dist/assets/MultiTerminalView-Co_RmbGo.css +1 -0
  65. sshler-1.0.4/sshler/static/dist/assets/MultiTerminalView-DdmBQ4zi.js +1 -0
  66. sshler-1.0.4/sshler/static/dist/assets/NotFoundView-CvkmUlRN.js +1 -0
  67. sshler-1.0.4/sshler/static/dist/assets/OverviewView-CgWwBOTj.css +1 -0
  68. sshler-1.0.4/sshler/static/dist/assets/OverviewView-G23xwjPD.js +1 -0
  69. sshler-1.0.2/sshler/static/dist/assets/PhArrowsClockwise.vue-BLHYPaFF.js → sshler-1.0.4/sshler/static/dist/assets/PhArrowsClockwise.vue-Bnf73duq.js +1 -1
  70. sshler-1.0.2/sshler/static/dist/assets/PhArrowsLeftRight.vue-BBZDx8HD.js → sshler-1.0.4/sshler/static/dist/assets/PhArrowsLeftRight.vue-Cc0p6Tcb.js +1 -1
  71. sshler-1.0.2/sshler/static/dist/assets/PhArrowsOut.vue-B8B678OT.js → sshler-1.0.4/sshler/static/dist/assets/PhArrowsOut.vue-CajEeaAm.js +1 -1
  72. sshler-1.0.2/sshler/static/dist/assets/PhCheck.vue-C9DFYQgy.js → sshler-1.0.4/sshler/static/dist/assets/PhCheck.vue-9N5IJbuq.js +1 -1
  73. sshler-1.0.2/sshler/static/dist/assets/PhCircle.vue-BfooAyte.js → sshler-1.0.4/sshler/static/dist/assets/PhCircle.vue-B2o4g103.js +1 -1
  74. sshler-1.0.2/sshler/static/dist/assets/PhClockCounterClockwise.vue-BLcbzc-B.js → sshler-1.0.4/sshler/static/dist/assets/PhClockCounterClockwise.vue-D8CL-v9G.js +1 -1
  75. sshler-1.0.2/sshler/static/dist/assets/PhCopy.vue-D3RzWNcP.js → sshler-1.0.4/sshler/static/dist/assets/PhCopy.vue-f7Nbq0qE.js +1 -1
  76. sshler-1.0.2/sshler/static/dist/assets/PhFloppyDisk.vue-B2uZ8xL_.js → sshler-1.0.4/sshler/static/dist/assets/PhFloppyDisk.vue-BZoj1mYq.js +1 -1
  77. sshler-1.0.2/sshler/static/dist/assets/PhFolderOpen.vue-DcCWZAYu.js → sshler-1.0.4/sshler/static/dist/assets/PhFolderOpen.vue-CZi6RSIx.js +1 -1
  78. sshler-1.0.2/sshler/static/dist/assets/PhGitBranch.vue-BK8iMGps.js → sshler-1.0.4/sshler/static/dist/assets/PhGitBranch.vue-BQ7VT_8f.js +1 -1
  79. sshler-1.0.4/sshler/static/dist/assets/PhList.vue-BvILwdE0.js +1 -0
  80. sshler-1.0.4/sshler/static/dist/assets/PhPencilSimple.vue-BO-v_vH4.js +1 -0
  81. sshler-1.0.2/sshler/static/dist/assets/PhPlay.vue-CE6G-I1m.js → sshler-1.0.4/sshler/static/dist/assets/PhPlay.vue-CQ9P9mkc.js +1 -1
  82. sshler-1.0.2/sshler/static/dist/assets/PhPushPinSimple.vue-CCYplIkb.js → sshler-1.0.4/sshler/static/dist/assets/PhPushPinSimple.vue-DW95sRoY.js +1 -1
  83. sshler-1.0.2/sshler/static/dist/assets/PhQuestion.vue-DOMPqSq0.js → sshler-1.0.4/sshler/static/dist/assets/PhQuestion.vue-n7SsA3hJ.js +1 -1
  84. sshler-1.0.2/sshler/static/dist/assets/PhTrash.vue-EX4P1cIo.js → sshler-1.0.4/sshler/static/dist/assets/PhTrash.vue-BmtI9lan.js +1 -1
  85. sshler-1.0.4/sshler/static/dist/assets/Popconfirm-BKhkqfQl.js +16 -0
  86. sshler-1.0.4/sshler/static/dist/assets/ProgressView-Bp8d9Q2f.js +1 -0
  87. sshler-1.0.4/sshler/static/dist/assets/ProgressView-D7O3-bB2.css +1 -0
  88. sshler-1.0.2/sshler/static/dist/assets/RadioGroup-DxCCyzGg.js → sshler-1.0.4/sshler/static/dist/assets/RadioGroup-C6nlDGIb.js +3 -3
  89. sshler-1.0.2/sshler/static/dist/assets/SettingsView-B1itMzkZ.js → sshler-1.0.4/sshler/static/dist/assets/SettingsView-CUUQmyKw.js +6 -6
  90. sshler-1.0.2/sshler/static/dist/assets/Spin-DyIxCRqg.js → sshler-1.0.4/sshler/static/dist/assets/Spin-CoAzscbC.js +3 -3
  91. sshler-1.0.2/sshler/static/dist/assets/Switch-CXzkKkdP.js → sshler-1.0.4/sshler/static/dist/assets/Switch-DAftDCaP.js +6 -6
  92. sshler-1.0.4/sshler/static/dist/assets/TerminalView-BqAcovL_.css +1 -0
  93. sshler-1.0.4/sshler/static/dist/assets/TerminalView-DLNGYZvp.js +2 -0
  94. sshler-1.0.2/sshler/static/dist/assets/arc-_G7dsxWN.js → sshler-1.0.4/sshler/static/dist/assets/arc-CErj3Lnn.js +1 -1
  95. sshler-1.0.4/sshler/static/dist/assets/architectureDiagram-3BPJPVTR-D_CeCdAb.js +36 -0
  96. sshler-1.0.2/sshler/static/dist/assets/blockDiagram-GPEHLZMM-BF8dyh4B.js → sshler-1.0.4/sshler/static/dist/assets/blockDiagram-GPEHLZMM-PH39VmUv.js +2 -2
  97. sshler-1.0.2/sshler/static/dist/assets/c4Diagram-AAUBKEIU-C3shTvrC.js → sshler-1.0.4/sshler/static/dist/assets/c4Diagram-AAUBKEIU-DCd1Tju9.js +5 -5
  98. sshler-1.0.4/sshler/static/dist/assets/channel-CcZ127p3.js +1 -0
  99. sshler-1.0.2/sshler/static/dist/assets/chunk-2J33WTMH-BlmNHocv.js → sshler-1.0.4/sshler/static/dist/assets/chunk-2J33WTMH-Bm3zYdqV.js +1 -1
  100. sshler-1.0.2/sshler/static/dist/assets/chunk-4BX2VUAB-FVsp0Dvs.js → sshler-1.0.4/sshler/static/dist/assets/chunk-4BX2VUAB-Ca2Yeljp.js +1 -1
  101. sshler-1.0.2/sshler/static/dist/assets/chunk-55IACEB6-BeaVCTsK.js → sshler-1.0.4/sshler/static/dist/assets/chunk-55IACEB6-BRPEdXmJ.js +1 -1
  102. sshler-1.0.2/sshler/static/dist/assets/chunk-727SXJPM-DOvH0YaL.js → sshler-1.0.4/sshler/static/dist/assets/chunk-727SXJPM-CfvJ_fzS.js +1 -1
  103. sshler-1.0.2/sshler/static/dist/assets/chunk-AQP2D5EJ-DwFGzbIM.js → sshler-1.0.4/sshler/static/dist/assets/chunk-AQP2D5EJ-D8sSsoIv.js +1 -1
  104. sshler-1.0.2/sshler/static/dist/assets/chunk-FMBD7UC4-DrOyMPnh.js → sshler-1.0.4/sshler/static/dist/assets/chunk-FMBD7UC4-CRdWUuCB.js +1 -1
  105. sshler-1.0.2/sshler/static/dist/assets/chunk-ND2GUHAM-bdPxSOzK.js → sshler-1.0.4/sshler/static/dist/assets/chunk-ND2GUHAM-BQm4Wh7T.js +1 -1
  106. sshler-1.0.2/sshler/static/dist/assets/chunk-QZHKN3VN-DF1WR5Ws.js → sshler-1.0.4/sshler/static/dist/assets/chunk-QZHKN3VN-C2XqKVRU.js +1 -1
  107. sshler-1.0.4/sshler/static/dist/assets/classDiagram-4FO5ZUOK-BQ9y1o3Z.js +1 -0
  108. sshler-1.0.4/sshler/static/dist/assets/classDiagram-v2-Q7XG4LA2-BQ9y1o3Z.js +1 -0
  109. sshler-1.0.4/sshler/static/dist/assets/composables-CHm4xsQx.js +1 -0
  110. sshler-1.0.4/sshler/static/dist/assets/cose-bilkent-S5V4N54A-ChoetsYb.js +1 -0
  111. sshler-1.0.4/sshler/static/dist/assets/dagre-BM42HDAG-BQ8GuBjY.js +4 -0
  112. sshler-1.0.4/sshler/static/dist/assets/diagram-2AECGRRQ-BjOrJsI1.js +43 -0
  113. sshler-1.0.4/sshler/static/dist/assets/diagram-5GNKFQAL-BvYMfHgT.js +10 -0
  114. sshler-1.0.2/sshler/static/dist/assets/diagram-KO2AKTUF-BaqYu-qh.js → sshler-1.0.4/sshler/static/dist/assets/diagram-KO2AKTUF-BZzxHlDH.js +2 -2
  115. sshler-1.0.4/sshler/static/dist/assets/diagram-LMA3HP47-jWRT2MAp.js +24 -0
  116. sshler-1.0.4/sshler/static/dist/assets/diagram-OG6HWLK6-B__bDIjw.js +24 -0
  117. sshler-1.0.2/sshler/static/dist/assets/erDiagram-TEJ5UH35-3hJqUA_3.js → sshler-1.0.4/sshler/static/dist/assets/erDiagram-TEJ5UH35-BC-njhki.js +2 -2
  118. sshler-1.0.2/sshler/static/dist/assets/favorites-DhWkZ_Ge.js → sshler-1.0.4/sshler/static/dist/assets/favorites--3uuqVsS.js +1 -1
  119. sshler-1.0.2/sshler/static/dist/assets/flowDiagram-I6XJVG4X-DjLO6b70.js → sshler-1.0.4/sshler/static/dist/assets/flowDiagram-I6XJVG4X-6um4yyFN.js +2 -2
  120. sshler-1.0.4/sshler/static/dist/assets/ganttDiagram-6RSMTGT7-D5uBkAu_.js +292 -0
  121. sshler-1.0.4/sshler/static/dist/assets/gitGraphDiagram-PVQCEYII-CqzevUa1.js +106 -0
  122. sshler-1.0.2/sshler/static/dist/assets/graph-1mebsZ_B.js → sshler-1.0.4/sshler/static/dist/assets/graph-C0t5zfn0.js +1 -1
  123. sshler-1.0.2/sshler/static/dist/assets/index-Dewesw9U.css → sshler-1.0.4/sshler/static/dist/assets/index-DgzHEFf2.css +1 -1
  124. sshler-1.0.4/sshler/static/dist/assets/index-ns_CMe7O.js +2136 -0
  125. sshler-1.0.4/sshler/static/dist/assets/infoDiagram-5YYISTIA-CQzowT10.js +2 -0
  126. sshler-1.0.2/sshler/static/dist/assets/ishikawaDiagram-YF4QCWOH-CgGECNI3.js → sshler-1.0.4/sshler/static/dist/assets/ishikawaDiagram-YF4QCWOH-iKU8fM5N.js +4 -4
  127. sshler-1.0.2/sshler/static/dist/assets/journeyDiagram-JHISSGLW-CnV6MMWs.js → sshler-1.0.4/sshler/static/dist/assets/journeyDiagram-JHISSGLW-C6GTMv4s.js +2 -2
  128. sshler-1.0.2/sshler/static/dist/assets/kanban-definition-UN3LZRKU-CNYDNKQ8.js → sshler-1.0.4/sshler/static/dist/assets/kanban-definition-UN3LZRKU-JVaFOXRH.js +2 -2
  129. sshler-1.0.2/sshler/static/dist/assets/layout-CDPPC1l5.js → sshler-1.0.4/sshler/static/dist/assets/layout-Ps6ZhZBc.js +1 -1
  130. sshler-1.0.2/sshler/static/dist/assets/linear-wLmeA8y8.js → sshler-1.0.4/sshler/static/dist/assets/linear-Ditq_Nbq.js +1 -1
  131. sshler-1.0.2/sshler/static/dist/assets/mermaid.core-a_pRy7_d.js → sshler-1.0.4/sshler/static/dist/assets/mermaid.core-D6mU6Y_x.js +4 -4
  132. sshler-1.0.2/sshler/static/dist/assets/mindmap-definition-RKZ34NQL-CdCNJ1c3.js → sshler-1.0.4/sshler/static/dist/assets/mindmap-definition-RKZ34NQL-Bp-2p-Zq.js +2 -2
  133. sshler-1.0.4/sshler/static/dist/assets/pieDiagram-4H26LBE5-CtTy4ufr.js +30 -0
  134. sshler-1.0.2/sshler/static/dist/assets/quadrantDiagram-W4KKPZXB-DP62iaWi.js → sshler-1.0.4/sshler/static/dist/assets/quadrantDiagram-W4KKPZXB-C9ptMwbQ.js +2 -2
  135. sshler-1.0.2/sshler/static/dist/assets/requirementDiagram-4Y6WPE33-D80_Wixp.js → sshler-1.0.4/sshler/static/dist/assets/requirementDiagram-4Y6WPE33-BYfEw_Jo.js +2 -2
  136. sshler-1.0.4/sshler/static/dist/assets/sankeyDiagram-5OEKKPKP-B5mhtWia.js +40 -0
  137. sshler-1.0.2/sshler/static/dist/assets/sequenceDiagram-3UESZ5HK-B82dIY8s.js → sshler-1.0.4/sshler/static/dist/assets/sequenceDiagram-3UESZ5HK-Bz8B9Vca.js +2 -2
  138. sshler-1.0.4/sshler/static/dist/assets/sessionName-CDrCe_qO.js +1 -0
  139. sshler-1.0.4/sshler/static/dist/assets/stateDiagram-AJRCARHV-DsYXZtCD.js +1 -0
  140. sshler-1.0.4/sshler/static/dist/assets/stateDiagram-v2-BHNVJYJU-DegVwMrL.js +1 -0
  141. sshler-1.0.2/sshler/static/dist/assets/timeline-definition-PNZ67QCA-BLrTakYZ.js → sshler-1.0.4/sshler/static/dist/assets/timeline-definition-PNZ67QCA-CjcCBS1J.js +10 -10
  142. sshler-1.0.2/sshler/static/dist/assets/vennDiagram-CIIHVFJN-B5Ccpd4s.js → sshler-1.0.4/sshler/static/dist/assets/vennDiagram-CIIHVFJN-Bcpt4jlg.js +2 -2
  143. sshler-1.0.2/sshler/static/dist/assets/wardley-L42UT6IY-CXaSjfFd.js → sshler-1.0.4/sshler/static/dist/assets/wardley-L42UT6IY-BgY4nRdk.js +1 -1
  144. sshler-1.0.2/sshler/static/dist/assets/wardleyDiagram-YWT4CUSO-Dyh7zrS3.js → sshler-1.0.4/sshler/static/dist/assets/wardleyDiagram-YWT4CUSO-DX8Rgphj.js +2 -2
  145. sshler-1.0.2/sshler/static/dist/assets/xychartDiagram-2RQKCTM6-C7ZRiqX6.js → sshler-1.0.4/sshler/static/dist/assets/xychartDiagram-2RQKCTM6-pAiLUkHO.js +2 -2
  146. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/index.html +2 -2
  147. sshler-1.0.4/sshler/tmux.py +381 -0
  148. {sshler-1.0.2 → sshler-1.0.4}/sshler/validation.py +4 -4
  149. {sshler-1.0.2 → sshler-1.0.4}/sshler/webapp.py +333 -146
  150. {sshler-1.0.2 → sshler-1.0.4}/sshler/win_terminal_registry.py +2 -1
  151. {sshler-1.0.2 → sshler-1.0.4}/sshler/winpty_proc.py +2 -2
  152. {sshler-1.0.2 → sshler-1.0.4/sshler.egg-info}/PKG-INFO +85 -5
  153. sshler-1.0.4/sshler.egg-info/SOURCES.txt +238 -0
  154. {sshler-1.0.2 → sshler-1.0.4}/sshler.egg-info/requires.txt +1 -0
  155. sshler-1.0.4/tests/test_api_artifacts.py +276 -0
  156. {sshler-1.0.2 → sshler-1.0.4}/tests/test_api_diff.py +67 -46
  157. {sshler-1.0.2 → sshler-1.0.4}/tests/test_api_progress.py +43 -44
  158. {sshler-1.0.2 → sshler-1.0.4}/tests/test_api_v1.py +80 -41
  159. {sshler-1.0.2 → sshler-1.0.4}/tests/test_archive.py +25 -26
  160. sshler-1.0.4/tests/test_artifacts.py +325 -0
  161. sshler-1.0.4/tests/test_basic.py +38 -0
  162. {sshler-1.0.2 → sshler-1.0.4}/tests/test_batch_ops.py +21 -22
  163. {sshler-1.0.2 → sshler-1.0.4}/tests/test_claude_sessions.py +52 -9
  164. sshler-1.0.4/tests/test_cli_artifacts.py +170 -0
  165. {sshler-1.0.2 → sshler-1.0.4}/tests/test_cli_progress.py +0 -3
  166. sshler-1.0.4/tests/test_client_address.py +345 -0
  167. {sshler-1.0.2 → sshler-1.0.4}/tests/test_command_injection.py +78 -66
  168. {sshler-1.0.2 → sshler-1.0.4}/tests/test_database_indexes.py +21 -11
  169. sshler-1.0.4/tests/test_dev_workflow.py +261 -0
  170. sshler-1.0.4/tests/test_fake_tmux.py +47 -0
  171. sshler-1.0.4/tests/test_files_folder_zip_remote.py +1328 -0
  172. sshler-1.0.4/tests/test_files_upload_local.py +131 -0
  173. sshler-1.0.4/tests/test_force_kill.py +453 -0
  174. sshler-1.0.4/tests/test_grep.py +376 -0
  175. {sshler-1.0.2 → sshler-1.0.4}/tests/test_handshake_status.py +12 -8
  176. sshler-1.0.4/tests/test_httpx_ws.py +72 -0
  177. sshler-1.0.4/tests/test_origin_csrf.py +105 -0
  178. {sshler-1.0.2 → sshler-1.0.4}/tests/test_path_validation.py +12 -11
  179. sshler-1.0.4/tests/test_platform_support.py +122 -0
  180. {sshler-1.0.2 → sshler-1.0.4}/tests/test_progress_websocket.py +14 -16
  181. sshler-1.0.4/tests/test_rate_limit.py +231 -0
  182. sshler-1.0.4/tests/test_rate_limit_middleware.py +255 -0
  183. {sshler-1.0.2 → sshler-1.0.4}/tests/test_recovery.py +26 -8
  184. sshler-1.0.4/tests/test_remote_git_info_quoting.py +82 -0
  185. sshler-1.0.4/tests/test_remote_tilde_routes.py +284 -0
  186. sshler-1.0.4/tests/test_search.py +646 -0
  187. {sshler-1.0.2 → sshler-1.0.4}/tests/test_serve_token_cache.py +0 -1
  188. sshler-1.0.4/tests/test_session_auth.py +416 -0
  189. sshler-1.0.4/tests/test_session_name_parity.py +50 -0
  190. sshler-1.0.4/tests/test_snapshot_loop_settings.py +139 -0
  191. sshler-1.0.4/tests/test_ssh_connection_leaks.py +1242 -0
  192. sshler-1.0.4/tests/test_ssh_pool_age.py +218 -0
  193. {sshler-1.0.2 → sshler-1.0.4}/tests/test_stats_stream.py +8 -9
  194. sshler-1.0.4/tests/test_terminal_teardown.py +509 -0
  195. sshler-1.0.4/tests/test_tmux_subprocess.py +439 -0
  196. {sshler-1.0.2 → sshler-1.0.4}/tests/test_tunnels.py +3 -2
  197. {sshler-1.0.2 → sshler-1.0.4}/tests/test_websocket.py +31 -38
  198. {sshler-1.0.2 → sshler-1.0.4}/tests/test_win_terminal_registry.py +111 -57
  199. {sshler-1.0.2 → sshler-1.0.4}/tests/test_windows_terminal.py +87 -45
  200. sshler-1.0.4/tests/test_ws_term_auth.py +244 -0
  201. sshler-1.0.2/sshler/api/grep.py +0 -160
  202. sshler-1.0.2/sshler/api/search.py +0 -192
  203. sshler-1.0.2/sshler/api/stats_stream.py +0 -98
  204. sshler-1.0.2/sshler/ssh_pool.py +0 -409
  205. sshler-1.0.2/sshler/static/dist/assets/ClaudeSessionsView-CGbHeAaj.js +0 -137
  206. sshler-1.0.2/sshler/static/dist/assets/ClaudeSessionsView-DN9qPL2F.css +0 -1
  207. sshler-1.0.2/sshler/static/dist/assets/CommanderView-CeqW512i.js +0 -1
  208. sshler-1.0.2/sshler/static/dist/assets/DiffView-DKQldLz_.js +0 -10
  209. sshler-1.0.2/sshler/static/dist/assets/DirectoryPickerModal-BJGCZjfT.css +0 -1
  210. sshler-1.0.2/sshler/static/dist/assets/DirectoryPickerModal-g2DqKz-v.js +0 -79
  211. sshler-1.0.2/sshler/static/dist/assets/FileEditorModal-CgM0I0XI.css +0 -1
  212. sshler-1.0.2/sshler/static/dist/assets/FileEditorModal-mQQlSzLp.js +0 -352
  213. sshler-1.0.2/sshler/static/dist/assets/FilesView-Be4yclnr.css +0 -1
  214. sshler-1.0.2/sshler/static/dist/assets/FilesView-BhxBCK3C.js +0 -751
  215. sshler-1.0.2/sshler/static/dist/assets/GitBadge-B_4M8JOq.js +0 -1
  216. sshler-1.0.2/sshler/static/dist/assets/MultiTerminalView-DDARrSaQ.css +0 -1
  217. sshler-1.0.2/sshler/static/dist/assets/MultiTerminalView-hud2MPaZ.js +0 -1
  218. sshler-1.0.2/sshler/static/dist/assets/NotFoundView-NdvchLjg.js +0 -1
  219. sshler-1.0.2/sshler/static/dist/assets/OverviewView-CL0qAbxt.css +0 -1
  220. sshler-1.0.2/sshler/static/dist/assets/OverviewView-D8TLlPQz.js +0 -1
  221. sshler-1.0.2/sshler/static/dist/assets/PhList.vue-CuXqS-Yu.js +0 -1
  222. sshler-1.0.2/sshler/static/dist/assets/Popconfirm-CgFPK9F0.js +0 -16
  223. sshler-1.0.2/sshler/static/dist/assets/ProgressView-DdmDJk_g.css +0 -1
  224. sshler-1.0.2/sshler/static/dist/assets/ProgressView-tCTPu2yW.js +0 -1
  225. sshler-1.0.2/sshler/static/dist/assets/TerminalView-COCfd2uh.css +0 -1
  226. sshler-1.0.2/sshler/static/dist/assets/TerminalView-DWSrSgDS.js +0 -2
  227. sshler-1.0.2/sshler/static/dist/assets/architectureDiagram-3BPJPVTR-BuMa823z.js +0 -36
  228. sshler-1.0.2/sshler/static/dist/assets/channel-OQURwrS_.js +0 -1
  229. sshler-1.0.2/sshler/static/dist/assets/classDiagram-4FO5ZUOK-BcXsVGrF.js +0 -1
  230. sshler-1.0.2/sshler/static/dist/assets/classDiagram-v2-Q7XG4LA2-BcXsVGrF.js +0 -1
  231. sshler-1.0.2/sshler/static/dist/assets/cose-bilkent-S5V4N54A-C-3AhSiq.js +0 -1
  232. sshler-1.0.2/sshler/static/dist/assets/dagre-BM42HDAG-BGYV-hKU.js +0 -4
  233. sshler-1.0.2/sshler/static/dist/assets/diagram-2AECGRRQ-HqKo4hRm.js +0 -43
  234. sshler-1.0.2/sshler/static/dist/assets/diagram-5GNKFQAL-DxQKZDCI.js +0 -10
  235. sshler-1.0.2/sshler/static/dist/assets/diagram-LMA3HP47-BSXDrPx5.js +0 -24
  236. sshler-1.0.2/sshler/static/dist/assets/diagram-OG6HWLK6-fbUkjsCH.js +0 -24
  237. sshler-1.0.2/sshler/static/dist/assets/ganttDiagram-6RSMTGT7-DDZ9gMyj.js +0 -292
  238. sshler-1.0.2/sshler/static/dist/assets/gitGraphDiagram-PVQCEYII-Z1pBUCOv.js +0 -106
  239. sshler-1.0.2/sshler/static/dist/assets/index-CeAHJWSQ.js +0 -2136
  240. sshler-1.0.2/sshler/static/dist/assets/infoDiagram-5YYISTIA-SEqseSvm.js +0 -2
  241. sshler-1.0.2/sshler/static/dist/assets/pieDiagram-4H26LBE5-CGk3s4aU.js +0 -30
  242. sshler-1.0.2/sshler/static/dist/assets/sankeyDiagram-5OEKKPKP-G9LZKWmT.js +0 -40
  243. sshler-1.0.2/sshler/static/dist/assets/sessionName-DbO6vfWs.js +0 -1
  244. sshler-1.0.2/sshler/static/dist/assets/stateDiagram-AJRCARHV-Bdc9qNnY.js +0 -1
  245. sshler-1.0.2/sshler/static/dist/assets/stateDiagram-v2-BHNVJYJU-BdScRmDh.js +0 -1
  246. sshler-1.0.2/sshler/tmux.py +0 -268
  247. sshler-1.0.2/sshler.egg-info/SOURCES.txt +0 -212
  248. sshler-1.0.2/tests/test_basic.py +0 -36
  249. sshler-1.0.2/tests/test_dev_workflow.py +0 -257
  250. sshler-1.0.2/tests/test_grep.py +0 -231
  251. sshler-1.0.2/tests/test_httpx_ws.py +0 -46
  252. sshler-1.0.2/tests/test_rate_limit.py +0 -226
  253. sshler-1.0.2/tests/test_search.py +0 -347
  254. sshler-1.0.2/tests/test_session_auth.py +0 -180
  255. {sshler-1.0.2 → sshler-1.0.4}/LICENSE +0 -0
  256. {sshler-1.0.2 → sshler-1.0.4}/MANIFEST.in +0 -0
  257. {sshler-1.0.2 → sshler-1.0.4}/scripts/check_frontend_dist.py +0 -0
  258. {sshler-1.0.2 → sshler-1.0.4}/setup.cfg +0 -0
  259. {sshler-1.0.2 → sshler-1.0.4}/sshler/__init__.py +0 -0
  260. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/pdf.py +0 -0
  261. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/ping.py +0 -0
  262. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/progress.py +0 -0
  263. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/snippets.py +0 -0
  264. {sshler-1.0.2 → sshler-1.0.4}/sshler/api/terminal.py +0 -0
  265. {sshler-1.0.2 → sshler-1.0.4}/sshler/scripts/install-sshler-task.ps1 +0 -0
  266. {sshler-1.0.2 → sshler-1.0.4}/sshler/scripts/remove-sshler-task.ps1 +0 -0
  267. {sshler-1.0.2 → sshler-1.0.4}/sshler/scripts/run-sshler.ps1 +0 -0
  268. {sshler-1.0.2 → sshler-1.0.4}/sshler/ssh_config.py +0 -0
  269. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/BoxesView-Cw5NiFP6.css +0 -0
  270. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/DiffViewer-Ds8qyy47.css +0 -0
  271. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/GitBadge-sLcMA5A6.css +0 -0
  272. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/LoginView-BLrnakeI.css +0 -0
  273. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/NotFoundView-DYaoqkDb.css +0 -0
  274. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/SettingsView-yHE_fgSm.css +0 -0
  275. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/cytoscape.esm-ChuFH5G7.js +0 -0
  276. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/defaultLocale-DX6XiGOO.js +0 -0
  277. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/init-Gi6I4Gst.js +0 -0
  278. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/katex-BbnesVCl.js +0 -0
  279. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/assets/ordinal-Cboi1Yqb.js +0 -0
  280. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/favicon.png +0 -0
  281. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/fonts/MonaspaceNeon-Bold.woff2 +0 -0
  282. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/fonts/MonaspaceNeon-Regular.woff2 +0 -0
  283. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/fonts/MonaspaceNeon-Variable.woff2 +0 -0
  284. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/icon-192.png +0 -0
  285. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/icon-512-maskable.png +0 -0
  286. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/icon-512.png +0 -0
  287. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/logo.png +0 -0
  288. {sshler-1.0.2 → sshler-1.0.4}/sshler/static/dist/manifest.webmanifest +0 -0
  289. {sshler-1.0.2 → sshler-1.0.4}/sshler.egg-info/dependency_links.txt +0 -0
  290. {sshler-1.0.2 → sshler-1.0.4}/sshler.egg-info/entry_points.txt +0 -0
  291. {sshler-1.0.2 → sshler-1.0.4}/sshler.egg-info/top_level.txt +0 -0
  292. {sshler-1.0.2 → sshler-1.0.4}/tests/test_config.py +0 -0
  293. {sshler-1.0.2 → sshler-1.0.4}/tests/test_snippets.py +0 -0
  294. {sshler-1.0.2 → sshler-1.0.4}/tests/test_ssh.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshler
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: A local FastAPI-powered SSH multiplexer for tmux-in-browser — from your laptop only.
5
5
  Author-email: Gabu <gabu@gabu.quest>
6
6
  License-Expression: MIT
@@ -45,6 +45,7 @@ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
45
45
  Requires-Dist: playwright>=1.48.0; extra == "dev"
46
46
  Requires-Dist: pytest-playwright>=0.5.0; extra == "dev"
47
47
  Requires-Dist: ruff>=0.5.6; extra == "dev"
48
+ Requires-Dist: pytest-timeout>=2.3; extra == "dev"
48
49
  Provides-Extra: pdf
49
50
  Requires-Dist: playwright>=1.48.0; extra == "pdf"
50
51
  Dynamic: license-file
@@ -189,6 +190,12 @@ The app opens at `http://127.0.0.1:8822` and redirects to the Vue SPA at `/app/`
189
190
  - **Honest percentages** - The displayed percent is rounded **down**: a 3300-step build at 3299/3300 reads 99%, never a misleading 100% until it's actually done.
190
191
  - **Live updates** - One WebSocket (`/ws/progress`) fans out every upsert/delete to every connected browser tab. Multi-machine? Open `/app/progress` on your phone too.
191
192
 
193
+ **🌐 Local HTML Artifacts**
194
+ - **One catalog for generated HTML** - Register a single page, a static site, or a recursively discovered collection under a required project and optional nested group.
195
+ - **Agent- and script-friendly CLI** - `sshler artifact add ./report.html --project demo --group reports` registers a completed output; list, show, update, rescan, open, and remove commands cover its lifecycle.
196
+ - **Safe local viewing** - HTML is served from a separate loopback-only origin with strict path containment and no CORS permission. Inline JavaScript works without granting access to the main API origin.
197
+ - **Non-destructive cleanup** - Trash actions and CLI removal unregister catalog metadata only. Source files and directories remain untouched.
198
+
192
199
  **♿ Accessibility**
193
200
  - **WCAG 2.1 AA Compliant** - Semantic HTML, ARIA labels, keyboard navigation
194
201
  - **Screen Reader Support** - Proper focus management and announcements
@@ -306,6 +313,34 @@ Subscriptions are **per box**: open a box (Files/Terminal), then hit the `+` on
306
313
 
307
314
  See `examples/progress-bar-build-watcher.sh` for a copy-pasteable demo loop including `trap` → `--status failed` on error.
308
315
 
316
+ ### Registering generated HTML
317
+
318
+ Start sshler, create HTML anywhere inside your workspace, and register it by path:
319
+
320
+ ```bash
321
+ # One self-contained page
322
+ sshler artifact add ./report.html --project demo --group reports
323
+
324
+ # A static site with assets
325
+ sshler artifact add ./site --project demo --group prototypes --mode site --entry index.html
326
+
327
+ # Recursively discover HTML pages
328
+ sshler artifact add ./reports --project demo --group runs --discover
329
+
330
+ sshler artifact find "quarterly summary" --project demo
331
+ sshler artifact list --project demo
332
+ sshler artifact open <id>
333
+ sshler artifact remove <id>
334
+ ```
335
+
336
+ Open `/app/artifacts` to search and filter registrations, expand page lists by clicking an
337
+ artifact row, preview pages, and unregister with the trash action. Stable cross-links use
338
+ `/r/<project-slug>/<artifact-slug>/`; `artifact add --mount <path>` can preserve existing
339
+ root-relative links. Opening and previewing require a loopback address. `remove` always leaves
340
+ source files unchanged.
341
+
342
+ See [`ROADMAP-ARTIFACTS.md`](ROADMAP-ARTIFACTS.md) for the architecture and security model.
343
+
309
344
  ### Key Shortcuts
310
345
 
311
346
  - **Cmd/Ctrl+K** - Command palette
@@ -452,7 +487,10 @@ SSHLER_COOKIE_SECURE=false # Only for localhost dev!
452
487
  # /etc/caddy/Caddyfile or ~/Caddyfile
453
488
 
454
489
  sshler.company.internal {
455
- reverse_proxy localhost:8822
490
+ reverse_proxy localhost:8822 {
491
+ # Overwrite any X-Real-IP the client sent (see SSHLER_TRUST_PROXY_HEADERS)
492
+ header_up X-Real-IP {remote_host}
493
+ }
456
494
  }
457
495
  ```
458
496
 
@@ -463,6 +501,10 @@ SSHLER_COOKIE_SECURE=false # Only for localhost dev!
463
501
  SSHLER_PORT=8822
464
502
  SSHLER_PUBLIC_URL=https://sshler.company.internal
465
503
  SSHLER_COOKIE_SECURE=true # Required for HTTPS
504
+ # Caddy overwrites X-Real-IP (header_up above), so sshler may trust it for
505
+ # rate limits and login lockouts. Leave unset unless a proxy on this host
506
+ # overwrites X-Real-IP; otherwise sshler keys on the connection's address.
507
+ SSHLER_TRUST_PROXY_HEADERS=true
466
508
  ```
467
509
 
468
510
  4. Start Caddy:
@@ -573,6 +615,11 @@ server {
573
615
  }
574
616
  ```
575
617
 
618
+ `proxy_set_header X-Real-IP $remote_addr` overwrites any `X-Real-IP` the client sent,
619
+ so set `SSHLER_TRUST_PROXY_HEADERS=true` with this config. Without that setting sshler
620
+ ignores `X-Real-IP` and keys rate limits and login lockouts on the connection's address,
621
+ which behind a proxy is the proxy's.
622
+
576
623
  **Traefik:**
577
624
 
578
625
  ```yaml
@@ -735,12 +782,13 @@ If you use [Claude Code](https://claude.ai/code) to develop sshler, this repo sh
735
782
  - `docs/skills/markdown-preview/SKILL.md` — what renders, what doesn't, and quirks of the print-to-PDF pipeline.
736
783
  - `docs/skills/progress-bars/SKILL.md` — push protocol, WebSocket fan-out, per-box subscription model.
737
784
  - `docs/skills/diff-notebook/SKILL.md` — the multi-cell diff workspace: command parser, base64 URL state, server-side persistence, the "no new backend" architecture decision.
785
+ - `docs/skills/artifacts/SKILL.md` — create HTML in the current workspace, choose a serving mode, and register it in the catalog.
738
786
 
739
787
  Claude won't auto-discover these — they live in the repo but aren't registered with the Claude Code runtime. A common pattern is to **symlink them into your global skills directory** (typically `~/.claude/skills/`) so any Claude Code session on your machine can load them by name:
740
788
 
741
789
  ```bash
742
790
  # One-time setup per machine
743
- for skill in markdown-preview progress-bars diff-notebook; do
791
+ for skill in markdown-preview progress-bars diff-notebook artifacts; do
744
792
  mkdir -p ~/.claude/skills/sshler-$skill
745
793
  ln -sf "$PWD/docs/skills/$skill/SKILL.md" ~/.claude/skills/sshler-$skill/SKILL.md
746
794
  done
@@ -873,6 +921,12 @@ sshler serve
873
921
  - **正直なパーセンテージ** - 表示パーセントは**切り捨て**です。3300 ステップのビルドで 3299/3300 なら 99% と表示され、実際に完了するまで誤解を招く 100% にはなりません。
874
922
  - **ライブ更新** - WebSocket (`/ws/progress`) が接続中の全タブに upsert/delete イベントをファンアウト。複数マシン?スマホでも `/app/progress` を開けます。
875
923
 
924
+ **ローカル HTML 成果物**
925
+ - **生成 HTML を一か所で管理** - 単一ページ、静的サイト、再帰的に検出する HTML コレクションを、必須のプロジェクトと任意のネストしたグループに登録できます。
926
+ - **エージェントやスクリプト向け CLI** - `sshler artifact add ./report.html --project demo --group reports` で完成した出力を登録し、list、show、update、rescan、open、remove で管理できます。
927
+ - **安全なローカル表示** - HTML は厳密なパス制約と CORS 許可なしの別ループバックオリジンから配信されます。
928
+ - **非破壊の整理** - ゴミ箱操作と CLI の remove はカタログ情報だけを解除し、元のファイルやディレクトリは変更しません。
929
+
876
930
  **🤖 Claude セッションダッシュボード**
877
931
  - **`/app/claude` — Claude Code セッションの再開** - このマシン上で再開可能な [Claude Code](https://claude.ai/code) の会話(`~/.claude/projects/*/*.jsonl` から読み取り)を一覧表示し、ワンクリックでブラウザ内ターミナルに再開します。タイトルは Claude Code の `/resume` ピッカーと同じ規則です(`/rename` で付けた名前 → AI 生成タイトル → 最初のプロンプトの順)。
878
932
  - **Git リポジトリ単位でグループ化・折りたたみ可能** - セッションはリポジトリのルート単位でグループ化されます(`repo/subdir` で実行した会話も `/resume` と同様に `repo` の下に表示)。各グループは折りたたみ可能で、アプリの他の場所と同じディレクトリ絵文字が付きます。初期状態は折りたたみで、フィルタ入力で該当グループが自動展開され、「すべて展開/すべて折りたたむ」ボタンもあります。
@@ -997,6 +1051,19 @@ sshler progress delete build
997
1051
 
998
1052
  エラー時に `trap` → `--status failed` するデモループは `examples/progress-bar-build-watcher.sh` を参照。
999
1053
 
1054
+ ### 生成 HTML を登録する
1055
+
1056
+ ```bash
1057
+ sshler artifact add ./report.html --project demo --group reports
1058
+ sshler artifact add ./site --project demo --group prototypes --mode site --entry index.html
1059
+ sshler artifact add ./reports --project demo --group runs --discover
1060
+ sshler artifact list --project demo
1061
+ sshler artifact open <id>
1062
+ sshler artifact remove <id>
1063
+ ```
1064
+
1065
+ `/app/artifacts` でプロジェクト、登録、ページ検出、プレビュー、ゴミ箱操作を管理できます。開く・プレビューする機能はループバックアドレス経由で利用でき、`remove` は元のファイルを残します。設計とセキュリティモデルは [`ROADMAP-ARTIFACTS.md`](ROADMAP-ARTIFACTS.md) を参照してください。
1066
+
1000
1067
  ### キーショートカット
1001
1068
 
1002
1069
  - **Cmd/Ctrl+K** - コマンドパレット
@@ -1141,7 +1208,10 @@ SSHLER_COOKIE_SECURE=false # localhost 開発専用!
1141
1208
  # /etc/caddy/Caddyfile または ~/Caddyfile
1142
1209
 
1143
1210
  sshler.company.internal {
1144
- reverse_proxy localhost:8822
1211
+ reverse_proxy localhost:8822 {
1212
+ # クライアントが送った X-Real-IP を上書き(SSHLER_TRUST_PROXY_HEADERS 参照)
1213
+ header_up X-Real-IP {remote_host}
1214
+ }
1145
1215
  }
1146
1216
  ```
1147
1217
 
@@ -1152,6 +1222,10 @@ SSHLER_COOKIE_SECURE=false # localhost 開発専用!
1152
1222
  SSHLER_PORT=8822
1153
1223
  SSHLER_PUBLIC_URL=https://sshler.company.internal
1154
1224
  SSHLER_COOKIE_SECURE=true # HTTPS 必須
1225
+ # Caddy が X-Real-IP を上書きする(上記 header_up)ので、レート制限と
1226
+ # ログインロックアウトにその値を使ってよい。同じホストのプロキシが X-Real-IP を
1227
+ # 上書きしない構成では設定しないこと(接続元アドレスが使われる)。
1228
+ SSHLER_TRUST_PROXY_HEADERS=true
1155
1229
  ```
1156
1230
 
1157
1231
  4. Caddy を起動:
@@ -1262,6 +1336,11 @@ server {
1262
1336
  }
1263
1337
  ```
1264
1338
 
1339
+ `proxy_set_header X-Real-IP $remote_addr` はクライアントが送った `X-Real-IP` を上書きするので、
1340
+ この設定では `SSHLER_TRUST_PROXY_HEADERS=true` を設定してください。この設定がないと sshler は
1341
+ `X-Real-IP` を無視し、レート制限とログインロックアウトを接続元アドレス(プロキシの背後では
1342
+ プロキシのアドレス)で数えます。
1343
+
1265
1344
  **Traefik:**
1266
1345
 
1267
1346
  ```yaml
@@ -1393,12 +1472,13 @@ systemctl --user enable --now sshler.service
1393
1472
  - `docs/skills/markdown-preview/SKILL.md` — 何がレンダリングされ、何がされないか、そして印刷/PDF パイプラインの癖。
1394
1473
  - `docs/skills/progress-bars/SKILL.md` — プッシュプロトコル、WebSocket ファンアウト、ホストごとの購読モデル。
1395
1474
  - `docs/skills/diff-notebook/SKILL.md` — マルチセルの差分ワークスペース:コマンドパーサー、base64 URL 状態、サーバー側の永続化、「新しいバックエンドを作らない」という設計判断。
1475
+ - `docs/skills/artifacts/SKILL.md` — 現在のワークスペースで HTML を作成し、配信モードを選んでカタログへ登録する手順。
1396
1476
 
1397
1477
  Claude はこれらを自動では検出しません。リポジトリ内には存在しますが、Claude Code ランタイムには登録されていないためです。よく使われる方法は、**グローバルスキルディレクトリ(通常は `~/.claude/skills/`)へシンボリックリンクを張る**ことです。こうすると、このマシン上のどの Claude Code セッションからでも名前で読み込めるようになります:
1398
1478
 
1399
1479
  ```bash
1400
1480
  # マシンごとに一度だけセットアップ
1401
- for skill in markdown-preview progress-bars diff-notebook; do
1481
+ for skill in markdown-preview progress-bars diff-notebook artifacts; do
1402
1482
  mkdir -p ~/.claude/skills/sshler-$skill
1403
1483
  ln -sf "$PWD/docs/skills/$skill/SKILL.md" ~/.claude/skills/sshler-$skill/SKILL.md
1404
1484
  done
@@ -138,6 +138,12 @@ The app opens at `http://127.0.0.1:8822` and redirects to the Vue SPA at `/app/`
138
138
  - **Honest percentages** - The displayed percent is rounded **down**: a 3300-step build at 3299/3300 reads 99%, never a misleading 100% until it's actually done.
139
139
  - **Live updates** - One WebSocket (`/ws/progress`) fans out every upsert/delete to every connected browser tab. Multi-machine? Open `/app/progress` on your phone too.
140
140
 
141
+ **🌐 Local HTML Artifacts**
142
+ - **One catalog for generated HTML** - Register a single page, a static site, or a recursively discovered collection under a required project and optional nested group.
143
+ - **Agent- and script-friendly CLI** - `sshler artifact add ./report.html --project demo --group reports` registers a completed output; list, show, update, rescan, open, and remove commands cover its lifecycle.
144
+ - **Safe local viewing** - HTML is served from a separate loopback-only origin with strict path containment and no CORS permission. Inline JavaScript works without granting access to the main API origin.
145
+ - **Non-destructive cleanup** - Trash actions and CLI removal unregister catalog metadata only. Source files and directories remain untouched.
146
+
141
147
  **♿ Accessibility**
142
148
  - **WCAG 2.1 AA Compliant** - Semantic HTML, ARIA labels, keyboard navigation
143
149
  - **Screen Reader Support** - Proper focus management and announcements
@@ -255,6 +261,34 @@ Subscriptions are **per box**: open a box (Files/Terminal), then hit the `+` on
255
261
 
256
262
  See `examples/progress-bar-build-watcher.sh` for a copy-pasteable demo loop including `trap` → `--status failed` on error.
257
263
 
264
+ ### Registering generated HTML
265
+
266
+ Start sshler, create HTML anywhere inside your workspace, and register it by path:
267
+
268
+ ```bash
269
+ # One self-contained page
270
+ sshler artifact add ./report.html --project demo --group reports
271
+
272
+ # A static site with assets
273
+ sshler artifact add ./site --project demo --group prototypes --mode site --entry index.html
274
+
275
+ # Recursively discover HTML pages
276
+ sshler artifact add ./reports --project demo --group runs --discover
277
+
278
+ sshler artifact find "quarterly summary" --project demo
279
+ sshler artifact list --project demo
280
+ sshler artifact open <id>
281
+ sshler artifact remove <id>
282
+ ```
283
+
284
+ Open `/app/artifacts` to search and filter registrations, expand page lists by clicking an
285
+ artifact row, preview pages, and unregister with the trash action. Stable cross-links use
286
+ `/r/<project-slug>/<artifact-slug>/`; `artifact add --mount <path>` can preserve existing
287
+ root-relative links. Opening and previewing require a loopback address. `remove` always leaves
288
+ source files unchanged.
289
+
290
+ See [`ROADMAP-ARTIFACTS.md`](ROADMAP-ARTIFACTS.md) for the architecture and security model.
291
+
258
292
  ### Key Shortcuts
259
293
 
260
294
  - **Cmd/Ctrl+K** - Command palette
@@ -401,7 +435,10 @@ SSHLER_COOKIE_SECURE=false # Only for localhost dev!
401
435
  # /etc/caddy/Caddyfile or ~/Caddyfile
402
436
 
403
437
  sshler.company.internal {
404
- reverse_proxy localhost:8822
438
+ reverse_proxy localhost:8822 {
439
+ # Overwrite any X-Real-IP the client sent (see SSHLER_TRUST_PROXY_HEADERS)
440
+ header_up X-Real-IP {remote_host}
441
+ }
405
442
  }
406
443
  ```
407
444
 
@@ -412,6 +449,10 @@ SSHLER_COOKIE_SECURE=false # Only for localhost dev!
412
449
  SSHLER_PORT=8822
413
450
  SSHLER_PUBLIC_URL=https://sshler.company.internal
414
451
  SSHLER_COOKIE_SECURE=true # Required for HTTPS
452
+ # Caddy overwrites X-Real-IP (header_up above), so sshler may trust it for
453
+ # rate limits and login lockouts. Leave unset unless a proxy on this host
454
+ # overwrites X-Real-IP; otherwise sshler keys on the connection's address.
455
+ SSHLER_TRUST_PROXY_HEADERS=true
415
456
  ```
416
457
 
417
458
  4. Start Caddy:
@@ -522,6 +563,11 @@ server {
522
563
  }
523
564
  ```
524
565
 
566
+ `proxy_set_header X-Real-IP $remote_addr` overwrites any `X-Real-IP` the client sent,
567
+ so set `SSHLER_TRUST_PROXY_HEADERS=true` with this config. Without that setting sshler
568
+ ignores `X-Real-IP` and keys rate limits and login lockouts on the connection's address,
569
+ which behind a proxy is the proxy's.
570
+
525
571
  **Traefik:**
526
572
 
527
573
  ```yaml
@@ -684,12 +730,13 @@ If you use [Claude Code](https://claude.ai/code) to develop sshler, this repo sh
684
730
  - `docs/skills/markdown-preview/SKILL.md` — what renders, what doesn't, and quirks of the print-to-PDF pipeline.
685
731
  - `docs/skills/progress-bars/SKILL.md` — push protocol, WebSocket fan-out, per-box subscription model.
686
732
  - `docs/skills/diff-notebook/SKILL.md` — the multi-cell diff workspace: command parser, base64 URL state, server-side persistence, the "no new backend" architecture decision.
733
+ - `docs/skills/artifacts/SKILL.md` — create HTML in the current workspace, choose a serving mode, and register it in the catalog.
687
734
 
688
735
  Claude won't auto-discover these — they live in the repo but aren't registered with the Claude Code runtime. A common pattern is to **symlink them into your global skills directory** (typically `~/.claude/skills/`) so any Claude Code session on your machine can load them by name:
689
736
 
690
737
  ```bash
691
738
  # One-time setup per machine
692
- for skill in markdown-preview progress-bars diff-notebook; do
739
+ for skill in markdown-preview progress-bars diff-notebook artifacts; do
693
740
  mkdir -p ~/.claude/skills/sshler-$skill
694
741
  ln -sf "$PWD/docs/skills/$skill/SKILL.md" ~/.claude/skills/sshler-$skill/SKILL.md
695
742
  done
@@ -822,6 +869,12 @@ sshler serve
822
869
  - **正直なパーセンテージ** - 表示パーセントは**切り捨て**です。3300 ステップのビルドで 3299/3300 なら 99% と表示され、実際に完了するまで誤解を招く 100% にはなりません。
823
870
  - **ライブ更新** - WebSocket (`/ws/progress`) が接続中の全タブに upsert/delete イベントをファンアウト。複数マシン?スマホでも `/app/progress` を開けます。
824
871
 
872
+ **ローカル HTML 成果物**
873
+ - **生成 HTML を一か所で管理** - 単一ページ、静的サイト、再帰的に検出する HTML コレクションを、必須のプロジェクトと任意のネストしたグループに登録できます。
874
+ - **エージェントやスクリプト向け CLI** - `sshler artifact add ./report.html --project demo --group reports` で完成した出力を登録し、list、show、update、rescan、open、remove で管理できます。
875
+ - **安全なローカル表示** - HTML は厳密なパス制約と CORS 許可なしの別ループバックオリジンから配信されます。
876
+ - **非破壊の整理** - ゴミ箱操作と CLI の remove はカタログ情報だけを解除し、元のファイルやディレクトリは変更しません。
877
+
825
878
  **🤖 Claude セッションダッシュボード**
826
879
  - **`/app/claude` — Claude Code セッションの再開** - このマシン上で再開可能な [Claude Code](https://claude.ai/code) の会話(`~/.claude/projects/*/*.jsonl` から読み取り)を一覧表示し、ワンクリックでブラウザ内ターミナルに再開します。タイトルは Claude Code の `/resume` ピッカーと同じ規則です(`/rename` で付けた名前 → AI 生成タイトル → 最初のプロンプトの順)。
827
880
  - **Git リポジトリ単位でグループ化・折りたたみ可能** - セッションはリポジトリのルート単位でグループ化されます(`repo/subdir` で実行した会話も `/resume` と同様に `repo` の下に表示)。各グループは折りたたみ可能で、アプリの他の場所と同じディレクトリ絵文字が付きます。初期状態は折りたたみで、フィルタ入力で該当グループが自動展開され、「すべて展開/すべて折りたたむ」ボタンもあります。
@@ -946,6 +999,19 @@ sshler progress delete build
946
999
 
947
1000
  エラー時に `trap` → `--status failed` するデモループは `examples/progress-bar-build-watcher.sh` を参照。
948
1001
 
1002
+ ### 生成 HTML を登録する
1003
+
1004
+ ```bash
1005
+ sshler artifact add ./report.html --project demo --group reports
1006
+ sshler artifact add ./site --project demo --group prototypes --mode site --entry index.html
1007
+ sshler artifact add ./reports --project demo --group runs --discover
1008
+ sshler artifact list --project demo
1009
+ sshler artifact open <id>
1010
+ sshler artifact remove <id>
1011
+ ```
1012
+
1013
+ `/app/artifacts` でプロジェクト、登録、ページ検出、プレビュー、ゴミ箱操作を管理できます。開く・プレビューする機能はループバックアドレス経由で利用でき、`remove` は元のファイルを残します。設計とセキュリティモデルは [`ROADMAP-ARTIFACTS.md`](ROADMAP-ARTIFACTS.md) を参照してください。
1014
+
949
1015
  ### キーショートカット
950
1016
 
951
1017
  - **Cmd/Ctrl+K** - コマンドパレット
@@ -1090,7 +1156,10 @@ SSHLER_COOKIE_SECURE=false # localhost 開発専用!
1090
1156
  # /etc/caddy/Caddyfile または ~/Caddyfile
1091
1157
 
1092
1158
  sshler.company.internal {
1093
- reverse_proxy localhost:8822
1159
+ reverse_proxy localhost:8822 {
1160
+ # クライアントが送った X-Real-IP を上書き(SSHLER_TRUST_PROXY_HEADERS 参照)
1161
+ header_up X-Real-IP {remote_host}
1162
+ }
1094
1163
  }
1095
1164
  ```
1096
1165
 
@@ -1101,6 +1170,10 @@ SSHLER_COOKIE_SECURE=false # localhost 開発専用!
1101
1170
  SSHLER_PORT=8822
1102
1171
  SSHLER_PUBLIC_URL=https://sshler.company.internal
1103
1172
  SSHLER_COOKIE_SECURE=true # HTTPS 必須
1173
+ # Caddy が X-Real-IP を上書きする(上記 header_up)ので、レート制限と
1174
+ # ログインロックアウトにその値を使ってよい。同じホストのプロキシが X-Real-IP を
1175
+ # 上書きしない構成では設定しないこと(接続元アドレスが使われる)。
1176
+ SSHLER_TRUST_PROXY_HEADERS=true
1104
1177
  ```
1105
1178
 
1106
1179
  4. Caddy を起動:
@@ -1211,6 +1284,11 @@ server {
1211
1284
  }
1212
1285
  ```
1213
1286
 
1287
+ `proxy_set_header X-Real-IP $remote_addr` はクライアントが送った `X-Real-IP` を上書きするので、
1288
+ この設定では `SSHLER_TRUST_PROXY_HEADERS=true` を設定してください。この設定がないと sshler は
1289
+ `X-Real-IP` を無視し、レート制限とログインロックアウトを接続元アドレス(プロキシの背後では
1290
+ プロキシのアドレス)で数えます。
1291
+
1214
1292
  **Traefik:**
1215
1293
 
1216
1294
  ```yaml
@@ -1342,12 +1420,13 @@ systemctl --user enable --now sshler.service
1342
1420
  - `docs/skills/markdown-preview/SKILL.md` — 何がレンダリングされ、何がされないか、そして印刷/PDF パイプラインの癖。
1343
1421
  - `docs/skills/progress-bars/SKILL.md` — プッシュプロトコル、WebSocket ファンアウト、ホストごとの購読モデル。
1344
1422
  - `docs/skills/diff-notebook/SKILL.md` — マルチセルの差分ワークスペース:コマンドパーサー、base64 URL 状態、サーバー側の永続化、「新しいバックエンドを作らない」という設計判断。
1423
+ - `docs/skills/artifacts/SKILL.md` — 現在のワークスペースで HTML を作成し、配信モードを選んでカタログへ登録する手順。
1345
1424
 
1346
1425
  Claude はこれらを自動では検出しません。リポジトリ内には存在しますが、Claude Code ランタイムには登録されていないためです。よく使われる方法は、**グローバルスキルディレクトリ(通常は `~/.claude/skills/`)へシンボリックリンクを張る**ことです。こうすると、このマシン上のどの Claude Code セッションからでも名前で読み込めるようになります:
1347
1426
 
1348
1427
  ```bash
1349
1428
  # マシンごとに一度だけセットアップ
1350
- for skill in markdown-preview progress-bars diff-notebook; do
1429
+ for skill in markdown-preview progress-bars diff-notebook artifacts; do
1351
1430
  mkdir -p ~/.claude/skills/sshler-$skill
1352
1431
  ln -sf "$PWD/docs/skills/$skill/SKILL.md" ~/.claude/skills/sshler-$skill/SKILL.md
1353
1432
  done
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "sshler"
8
- version = "1.0.2"
8
+ version = "1.0.4"
9
9
  description = "A local FastAPI-powered SSH multiplexer for tmux-in-browser — from your laptop only."
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.12"
@@ -57,6 +57,7 @@ dev = [
57
57
  "playwright>=1.48.0",
58
58
  "pytest-playwright>=0.5.0",
59
59
  "ruff>=0.5.6",
60
+ "pytest-timeout>=2.3",
60
61
  ]
61
62
  pdf = [
62
63
  "playwright>=1.48.0",
@@ -76,6 +77,7 @@ dev = [
76
77
  "types-pyyaml>=6.0.12.20250915",
77
78
  "pre-commit>=4.0.0",
78
79
  "build>=1.4.0",
80
+ "pytest-timeout>=2.3",
79
81
  ]
80
82
 
81
83
  [project.scripts]
@@ -91,7 +93,7 @@ exclude = ["frontend*", "tests*"]
91
93
  sshler = ["static/**/*", "scripts/*.ps1"]
92
94
 
93
95
  [tool.ruff]
94
- target-version = "py310"
96
+ target-version = "py312"
95
97
  line-length = 100
96
98
 
97
99
  [tool.ruff.lint]
@@ -116,3 +118,13 @@ warn_redundant_casts = true
116
118
  warn_unused_ignores = true
117
119
  no_implicit_reexport = true
118
120
  strict_equality = true
121
+
122
+ [tool.pytest.ini_options]
123
+ testpaths = ["tests"]
124
+ asyncio_mode = "strict"
125
+ timeout = 60
126
+ timeout_method = "thread"
127
+ addopts = "--strict-markers"
128
+ markers = [
129
+ "e2e: Playwright end-to-end tests under tests/e2e (deselect with -m \"not e2e\")",
130
+ ]
@@ -3,13 +3,14 @@
3
3
  from fastapi import APIRouter, Depends
4
4
 
5
5
  from .archive import get_router as archive_router
6
- from .excel import get_router as excel_router
6
+ from .artifacts import get_router as artifacts_router
7
7
  from .batch import get_router as batch_router
8
8
  from .boxes import get_router as boxes_router
9
9
  from .claude_sessions import get_router as claude_sessions_router
10
10
  from .config import get_router as config_router
11
11
  from .dependencies import APIDependencies
12
12
  from .diff import get_router as diff_router
13
+ from .excel import get_router as excel_router
13
14
  from .files import get_router as files_router
14
15
  from .git import get_router as git_router
15
16
  from .grep import get_router as grep_router
@@ -27,7 +28,12 @@ from .tunnels import get_router as tunnels_router
27
28
 
28
29
 
29
30
  def create_api_router(deps: APIDependencies) -> APIRouter:
30
- api_router = APIRouter(prefix="/api/v1", tags=["api"], dependencies=[Depends(deps.require_token)])
31
+ api_router = APIRouter(
32
+ prefix="/api/v1",
33
+ tags=["api"],
34
+ dependencies=[Depends(deps.require_token)],
35
+ )
36
+ api_router.include_router(artifacts_router(deps))
31
37
  api_router.include_router(archive_router(deps))
32
38
  api_router.include_router(excel_router(deps))
33
39
  api_router.include_router(batch_router(deps))
@@ -18,7 +18,8 @@ from ..ssh import SSHError
18
18
  from ..ssh_pool import get_pool
19
19
  from ..validation import PathValidator, ValidationError
20
20
  from .dependencies import APIDependencies
21
- from .helpers import _normalize_local_path, _normalize_directory_path
21
+ from .files import _remote_path
22
+ from .helpers import _normalize_local_path
22
23
  from .rate_limiting import rate_limit_file_ops
23
24
 
24
25
  logger = logging.getLogger(__name__)
@@ -45,12 +46,14 @@ def _validate_archive_name(name: str, fmt: str) -> str:
45
46
  try:
46
47
  validated = PathValidator.validate_filename(name)
47
48
  except ValidationError as exc:
48
- raise HTTPException(status_code=400, detail=str(exc))
49
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
49
50
 
50
51
  expected_exts = {"tar.gz": (".tar.gz",), "tgz": (".tgz",), "zip": (".zip",)}
51
52
  valid_exts = expected_exts.get(fmt, ())
52
53
  if not any(validated.lower().endswith(ext) for ext in valid_exts):
53
- raise HTTPException(status_code=400, detail=f"Archive name must end in {' or '.join(valid_exts)}")
54
+ raise HTTPException(
55
+ status_code=400, detail=f"Archive name must end in {' or '.join(valid_exts)}"
56
+ )
54
57
 
55
58
  return validated
56
59
 
@@ -94,7 +97,10 @@ def get_router(deps: APIDependencies) -> APIRouter:
94
97
  if not payload.paths:
95
98
  raise HTTPException(status_code=400, detail="No paths provided")
96
99
  if payload.format not in VALID_FORMATS:
97
- raise HTTPException(status_code=400, detail=f"Invalid format. Must be one of: {', '.join(VALID_FORMATS)}")
100
+ raise HTTPException(
101
+ status_code=400,
102
+ detail=f"Invalid format. Must be one of: {', '.join(VALID_FORMATS)}",
103
+ )
98
104
 
99
105
  archive_name = _validate_archive_name(payload.archive_name, payload.format)
100
106
  box = deps.get_box_or_404(application_config, name)
@@ -126,7 +132,7 @@ def get_router(deps: APIDependencies) -> APIRouter:
126
132
  try:
127
133
  await asyncio.to_thread(_create_local)
128
134
  except Exception as exc:
129
- raise HTTPException(status_code=500, detail=str(exc))
135
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
130
136
 
131
137
  return {"status": "ok", "message": f"Created {archive_name}", "path": str(archive_path)}
132
138
 
@@ -134,41 +140,48 @@ def get_router(deps: APIDependencies) -> APIRouter:
134
140
  try:
135
141
  dest_validated = PathValidator.validate_remote_path(payload.destination)
136
142
  except ValidationError as exc:
137
- raise HTTPException(status_code=400, detail=str(exc))
143
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
138
144
 
139
145
  validated_paths: list[str] = []
140
146
  for p in payload.paths:
141
147
  try:
142
148
  validated_paths.append(PathValidator.validate_remote_path(p))
143
149
  except ValidationError as exc:
144
- raise HTTPException(status_code=400, detail=f"Invalid path {p}: {exc}")
145
-
146
- archive_target = str(PurePosixPath(dest_validated) / archive_name)
147
- quoted_paths = " ".join(shlex.quote(p) for p in validated_paths)
148
-
149
- if payload.format in ("tar.gz", "tgz"):
150
- cmd = f"tar czf {shlex.quote(archive_target)} -C / {quoted_paths}"
151
- else:
152
- cmd = f"cd / && zip -r {shlex.quote(archive_target)} {quoted_paths}"
150
+ raise HTTPException(status_code=400, detail=f"Invalid path {p}: {exc}") from exc
153
151
 
154
152
  ssh_pool = get_pool()
155
153
  try:
156
154
  async with ssh_pool.connection(
157
155
  box, lambda: deps.connect_for_box(box, application_config)
158
156
  ) as connection:
157
+ # SFTP and the quoted shell command do not expand `~`: resolve it here.
158
+ dest_resolved = await _remote_path(connection, dest_validated)
159
+ resolved_paths = [await _remote_path(connection, p) for p in validated_paths]
160
+ archive_target = str(PurePosixPath(dest_resolved) / archive_name)
161
+ quoted_paths = " ".join(shlex.quote(p) for p in resolved_paths)
162
+
163
+ if payload.format in ("tar.gz", "tgz"):
164
+ cmd = f"tar czf {shlex.quote(archive_target)} -C / {quoted_paths}"
165
+ else:
166
+ cmd = f"cd / && zip -r {shlex.quote(archive_target)} {quoted_paths}"
167
+
159
168
  try:
160
- result = await asyncio.wait_for(
169
+ await asyncio.wait_for(
161
170
  connection.run(cmd, check=True),
162
171
  timeout=ARCHIVE_TIMEOUT,
163
172
  )
164
- except asyncio.TimeoutError:
165
- raise HTTPException(status_code=504, detail="Archive creation timed out")
173
+ except TimeoutError as exc:
174
+ raise HTTPException(
175
+ status_code=504, detail="Archive creation timed out"
176
+ ) from exc
166
177
  except HTTPException:
167
178
  raise
179
+ except ValidationError as exc:
180
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
168
181
  except SSHError as exc:
169
- raise HTTPException(status_code=502, detail=str(exc))
182
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
170
183
  except Exception as exc:
171
- raise HTTPException(status_code=500, detail=str(exc))
184
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
172
185
 
173
186
  return {"status": "ok", "message": f"Created {archive_name}", "path": archive_target}
174
187
 
@@ -205,7 +218,7 @@ def get_router(deps: APIDependencies) -> APIRouter:
205
218
  except HTTPException:
206
219
  raise
207
220
  except Exception as exc:
208
- raise HTTPException(status_code=500, detail=str(exc))
221
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
209
222
 
210
223
  return {"status": "ok", "message": "Extracted", "path": str(dest_dir)}
211
224
 
@@ -214,14 +227,10 @@ def get_router(deps: APIDependencies) -> APIRouter:
214
227
  validated_archive = PathValidator.validate_remote_path(payload.archive_path)
215
228
  validated_dest = PathValidator.validate_remote_path(payload.destination)
216
229
  except ValidationError as exc:
217
- raise HTTPException(status_code=400, detail=str(exc))
230
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
218
231
 
219
232
  name_lower = PurePosixPath(validated_archive).name.lower()
220
- if name_lower.endswith((".tar.gz", ".tgz")):
221
- cmd = f"tar xzf {shlex.quote(validated_archive)} -C {shlex.quote(validated_dest)}"
222
- elif name_lower.endswith(".zip"):
223
- cmd = f"unzip -o {shlex.quote(validated_archive)} -d {shlex.quote(validated_dest)}"
224
- else:
233
+ if not name_lower.endswith((".tar.gz", ".tgz", ".zip")):
225
234
  raise HTTPException(status_code=400, detail="Unsupported archive format")
226
235
 
227
236
  ssh_pool = get_pool()
@@ -229,20 +238,33 @@ def get_router(deps: APIDependencies) -> APIRouter:
229
238
  async with ssh_pool.connection(
230
239
  box, lambda: deps.connect_for_box(box, application_config)
231
240
  ) as connection:
241
+ # SFTP and the quoted shell command do not expand `~`: resolve it here.
242
+ archive_resolved = await _remote_path(connection, validated_archive)
243
+ dest_resolved = await _remote_path(connection, validated_dest)
244
+ if name_lower.endswith((".tar.gz", ".tgz")):
245
+ cmd = (
246
+ f"tar xzf {shlex.quote(archive_resolved)} -C {shlex.quote(dest_resolved)}"
247
+ )
248
+ else:
249
+ cmd = (
250
+ f"unzip -o {shlex.quote(archive_resolved)} -d {shlex.quote(dest_resolved)}"
251
+ )
232
252
  try:
233
253
  await asyncio.wait_for(
234
254
  connection.run(cmd, check=True),
235
255
  timeout=ARCHIVE_TIMEOUT,
236
256
  )
237
- except asyncio.TimeoutError:
238
- raise HTTPException(status_code=504, detail="Extraction timed out")
257
+ except TimeoutError as exc:
258
+ raise HTTPException(status_code=504, detail="Extraction timed out") from exc
239
259
  except HTTPException:
240
260
  raise
261
+ except ValidationError as exc:
262
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
241
263
  except SSHError as exc:
242
- raise HTTPException(status_code=502, detail=str(exc))
264
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
243
265
  except Exception as exc:
244
- raise HTTPException(status_code=500, detail=str(exc))
266
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
245
267
 
246
- return {"status": "ok", "message": "Extracted", "path": validated_dest}
268
+ return {"status": "ok", "message": "Extracted", "path": dest_resolved}
247
269
 
248
270
  return router