pywebexec 1.9.2__py3-none-any.whl → 1.9.4__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.
pywebexec/pywebexec.py CHANGED
@@ -29,7 +29,8 @@ from pathlib import Path
29
29
  import pyte
30
30
  from . import host_ip
31
31
  from flask_swagger_ui import get_swaggerui_blueprint # new import
32
- import yaml # new import
32
+ import yaml
33
+ import html
33
34
 
34
35
  if os.environ.get('PYWEBEXEC_LDAP_SERVER'):
35
36
  from ldap3 import Server, Connection, ALL, SIMPLE, SUBTREE, Tls
@@ -167,12 +168,22 @@ def strip_ansi_control_chars(text):
167
168
  def decode_line(line: bytes) -> str:
168
169
  """try decode line exception on binary"""
169
170
  try:
170
- return get_visible_output(line.decode())
171
+ return get_visible_line(line.decode())
172
+ except UnicodeDecodeError:
173
+ return ""
174
+
175
+ def get_visible_output(output, cols, rows):
176
+ """pyte vt100 render to get last line"""
177
+ try:
178
+ screen = pyte.Screen(cols, rows)
179
+ stream = pyte.Stream(screen)
180
+ stream.feed(output)
181
+ return "\n".join(screen.display).strip()
171
182
  except UnicodeDecodeError:
172
183
  return ""
173
184
 
174
185
 
175
- def get_visible_output(line, cols, rows):
186
+ def get_visible_line(line, cols, rows):
176
187
  """pyte vt100 render to get last line"""
177
188
  try:
178
189
  screen = pyte.Screen(cols, rows)
@@ -197,7 +208,7 @@ def get_last_line(file_path, cols=None, rows=None, maxsize=2048):
197
208
  fd.seek(-maxsize, os.SEEK_END)
198
209
  except OSError:
199
210
  fd.seek(0)
200
- return get_visible_output(fd.read(), cols, rows)
211
+ return get_visible_line(fd.read(), cols, rows)
201
212
 
202
213
 
203
214
  def start_gunicorn(daemonized=False, baselog=None):
@@ -619,6 +630,19 @@ def log_error(fromip, user, message):
619
630
  def log_request(message):
620
631
  log_info(request.remote_addr, session.get('username', '-'), message)
621
632
 
633
+ def get_executables():
634
+ executables_list = []
635
+ for f in os.listdir('.'):
636
+ if os.path.isfile(f) and os.access(f, os.X_OK):
637
+ help_file = f"{f}.help"
638
+ help_text = ""
639
+ if os.path.exists(help_file) and os.path.isfile(help_file):
640
+ with open(help_file, 'r') as hf:
641
+ help_text = hf.read()
642
+ executables_list.append({"command": f, "help": help_text})
643
+ return executables_list
644
+
645
+
622
646
  @app.route('/commands/<command_id>/stop', methods=['PATCH'])
623
647
  def stop_command(command_id):
624
648
  log_request(f"stop_command {command_id}")
@@ -744,6 +768,37 @@ def run_command_endpoint():
744
768
 
745
769
  return jsonify({'message': 'Command is running', 'command_id': command_id})
746
770
 
771
+ @app.route('/commands/<cmd>', methods=['POST'])
772
+ def run_dynamic_command(cmd):
773
+ # Validate that 'cmd' is an executable in the current directory
774
+ cmd_path = os.path.join(".", os.path.basename(cmd))
775
+ if not os.path.isfile(cmd_path) or not os.access(cmd_path, os.X_OK):
776
+ return jsonify({'error': 'Command not found or not executable'}), 400
777
+ try:
778
+ data = request.json
779
+ except Exception as e:
780
+ data = {}
781
+ params = data.get('params', [])
782
+ rows = data.get('rows', tty_rows) or tty_rows
783
+ cols = data.get('cols', tty_cols) or tty_cols
784
+ try:
785
+ params = shlex.split(' '.join(params)) if isinstance(params, list) else []
786
+ except Exception as e:
787
+ return jsonify({'error': str(e)}), 400
788
+ user = session.get('username', '-')
789
+ command_id = str(uuid.uuid4())
790
+ update_command_status(command_id, {
791
+ 'status': 'running',
792
+ 'command': cmd,
793
+ 'params': params,
794
+ 'user': user,
795
+ 'from': request.remote_addr,
796
+ })
797
+ Path(get_output_file_path(command_id)).touch()
798
+ thread = threading.Thread(target=run_command, args=(request.remote_addr, user, cmd_path, params, command_id, rows, cols))
799
+ thread.start()
800
+ return jsonify({'message': 'Command is running', 'command_id': command_id})
801
+
747
802
  @app.route('/commands/<command_id>', methods=['GET'])
748
803
  def get_command_status(command_id):
749
804
  status = read_command_status(command_id)
@@ -757,15 +812,15 @@ def index():
757
812
 
758
813
  @app.route('/commands', methods=['GET'])
759
814
  def list_commands():
760
- # Sort commands by start_time in descending order
761
815
  commands = read_commands()
762
816
  commands.sort(key=lambda x: x['start_time'], reverse=True)
763
- return jsonify(commands)
817
+ return jsonify({"commands": commands})
764
818
 
765
819
  @app.route('/commands/<command_id>/output', methods=['GET'])
766
820
  def get_command_output(command_id):
767
821
  offset = int(request.args.get('offset', 0))
768
822
  maxsize = int(request.args.get('maxsize', 10485760))
823
+ maxlines = int(request.args.get('maxlines', 5000))
769
824
  output_file_path = get_output_file_path(command_id)
770
825
  if os.path.exists(output_file_path):
771
826
  size = os.path.getsize(output_file_path)
@@ -790,7 +845,7 @@ def get_command_output(command_id):
790
845
  }
791
846
  }
792
847
  if request.headers.get('Accept') == 'text/plain':
793
- return f"{output}\nstatus: {status_data.get('status')}", 200, {'Content-Type': 'text/plain'}
848
+ return f"{get_visible_output(output, status_data.get('cols'), maxlines)}\nstatus: {status_data.get('status')}", 200, {'Content-Type': 'text/plain'}
794
849
  return jsonify(response)
795
850
  return jsonify({'error': 'Invalid command_id'}), 404
796
851
 
@@ -820,9 +875,8 @@ def get_command_output_raw(command_id):
820
875
 
821
876
  @app.route('/executables', methods=['GET'])
822
877
  def list_executables():
823
- executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
824
- executables.sort() # Sort the list of executables alphabetically
825
- return jsonify(executables)
878
+ executables_list = get_executables()
879
+ return jsonify({"executables": executables_list})
826
880
 
827
881
  @app.route('/commands/<command_id>/popup')
828
882
  def popup(command_id):
@@ -854,7 +908,54 @@ def swagger_yaml():
854
908
  with open(swagger_path, 'r') as f:
855
909
  swagger_spec_str = f.read()
856
910
  swagger_spec = yaml.safe_load(swagger_spec_str)
857
- # Set title dynamically using the app configuration
911
+ # Update existing POST /commands enum if present
912
+ executables = get_executables()
913
+ post_cmd = swagger_spec.get('paths', {}).get('/commands', {}).get('post')
914
+ if post_cmd:
915
+ params_list = post_cmd.get('parameters', [])
916
+ for param in params_list:
917
+ if param.get('in') == 'body' and 'schema' in param:
918
+ props = param['schema'].get('properties', {})
919
+ if 'command' in props:
920
+ props['command']['enum'] = [e['command'] for e in executables]
921
+ # Add dynamic paths for each
922
+ # Add dynamic paths for each executable:
923
+ for exe in executables:
924
+ dynamic_path = "/commands/" + exe["command"]
925
+ swagger_spec.setdefault("paths", {})[dynamic_path] = {
926
+ "post": {
927
+ "summary": f"Run command {exe["command"]}",
928
+ "description": html.escape(exe["help"]),
929
+ "consumes": ["application/json"],
930
+ "produces": ["application/json"],
931
+ "parameters": [
932
+ {
933
+ "in": "body",
934
+ "name": "commandRequest",
935
+ "schema": {
936
+ "type": "object",
937
+ "properties": {
938
+ "params": {"type": "array", "items": {"type": "string"}, "default": []},
939
+ "rows": {"type": "integer", "default": tty_rows},
940
+ "cols": {"type": "integer", "default": tty_cols},
941
+ }
942
+ }
943
+ }
944
+ ],
945
+ "responses": {
946
+ "200": {
947
+ "description": "Command started",
948
+ "schema": {
949
+ "type": "object",
950
+ "properties": {
951
+ "message": {"type": "string"},
952
+ "command_id": {"type": "string"}
953
+ }
954
+ }
955
+ }
956
+ }
957
+ }
958
+ }
858
959
  swagger_spec['info']['title'] = app.config.get('TITLE', 'PyWebExec API')
859
960
  swagger_spec_str = yaml.dump(swagger_spec)
860
961
  return Response(swagger_spec_str, mimetype='application/yaml')
@@ -99,6 +99,18 @@ form {
99
99
  vertical-align: bottom;
100
100
  padding-left: 35px;
101
101
  }
102
+ #paramsHelp {
103
+ position: absolute;
104
+ background-color: #e0e0ff;
105
+ border: 1px solid #ccc;
106
+ padding: 4px 8px;
107
+ font-size: 0.9em;
108
+ display: none;
109
+ z-index: 1000;
110
+ white-space: pre;
111
+ border-radius: 5px;
112
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
113
+ }
102
114
  #thStatus {
103
115
  cursor: pointer;
104
116
  }
@@ -5,6 +5,8 @@ let commandListDiv = document.getElementById('commandList');
5
5
  let showCommandListButton = document.getElementById('showCommandListButton');
6
6
  let isHandlingKeydown = false;
7
7
  let firstVisibleItem = 0;
8
+ let gExecutables = {};
9
+ let helpDiv = document.getElementById('paramsHelp');
8
10
 
9
11
  function unfilterCommands() {
10
12
  const items = commandListDiv.children;
@@ -43,6 +45,13 @@ function setCommandListPosition() {
43
45
  commandListDiv.style.top = `${rect.bottom}px`;
44
46
  }
45
47
 
48
+ // Update helpDiv position relative to paramsInput
49
+ function setHelpDivPosition() {
50
+ const rect = paramsInput.getBoundingClientRect();
51
+ helpDiv.style.left = `${rect.left}px`;
52
+ helpDiv.style.top = `${rect.bottom + 2}px`;
53
+ }
54
+
46
55
  function adjustInputWidth(input) {
47
56
  input.style.width = 'auto';
48
57
  input.style.width = `${input.scrollWidth}px`;
@@ -216,17 +225,23 @@ window.addEventListener('load', () => {
216
225
 
217
226
  async function fetchExecutables() {
218
227
  try {
219
- const response = await fetch(`/executables${urlToken}`);
228
+ const response = await fetch(`/executables`);
220
229
  if (!response.ok) {
221
- throw new Error('Failed to fetch command status');
230
+ throw new Error('Failed to fetch executables');
222
231
  }
223
- const executables = await response.json();
232
+ const data = await response.json();
233
+ // Build mapping from executable name to its object
234
+ gExecutables = {};
224
235
  commandListDiv.innerHTML = '';
225
- executables.forEach(executable => {
236
+ data.executables.forEach(exeObj => {
237
+ gExecutables[exeObj.command] = exeObj;
226
238
  const div = document.createElement('div');
227
239
  div.className = 'command-item';
228
- div.textContent = executable;
229
- div.tabIndex = 0; // Make the div focusable
240
+ div.textContent = exeObj.command;
241
+ div.tabIndex = 0;
242
+ if (exeObj.help) {
243
+ div.title = exeObj.help;
244
+ }
230
245
  commandListDiv.appendChild(div);
231
246
  });
232
247
  } catch (error) {
@@ -240,3 +255,22 @@ async function fetchExecutables() {
240
255
  document.getElementById('launchForm').style.display = 'none';
241
256
 
242
257
  }
258
+
259
+ paramsInput.addEventListener('focus', () => {
260
+ const currentCmd = commandInput.value;
261
+ if (gExecutables[currentCmd] && gExecutables[currentCmd].help) {
262
+ //helpDiv.innerHTML = gExecutables[currentCmd].help.replace(/\n/g, '<br>');
263
+ helpDiv.innerText = gExecutables[currentCmd].help;
264
+ setHelpDivPosition();
265
+ helpDiv.style.display = 'block';
266
+ } else {
267
+ helpDiv.style.display = 'none';
268
+ }
269
+ });
270
+
271
+ paramsInput.addEventListener('blur', () => {
272
+ helpDiv.style.display = 'none';
273
+ });
274
+
275
+ window.addEventListener('resize', setHelpDivPosition);
276
+ window.addEventListener('scroll', setHelpDivPosition);
@@ -98,11 +98,6 @@ function autoFit(scroll=true) {
98
98
  if (scroll) terminal.scrollToBottom();
99
99
  }
100
100
 
101
- function getTokenParam() {
102
- const urlParams = new URLSearchParams(window.location.search);
103
- return urlParams.get('token') ? `?token=${urlParams.get('token')}` : '';
104
- }
105
- const urlToken = getTokenParam();
106
101
 
107
102
 
108
103
  function setCommandStatus(status) {
@@ -161,14 +156,14 @@ async function viewOutput(command_id) {
161
156
  slider.value = 1000;
162
157
  adjustOutputHeight();
163
158
  currentCommandId = command_id;
164
- nextOutputLink = `/commands/${command_id}/output${urlToken}`;
159
+ nextOutputLink = `/commands/${command_id}/output`;
165
160
  clearInterval(outputInterval);
166
161
  terminal.clear();
167
162
  terminal.reset();
168
163
  fullOutput = '';
169
164
  try {
170
165
  // Updated endpoint below:
171
- const response = await fetch(`/commands/${command_id}${urlToken}`);
166
+ const response = await fetch(`/commands/${command_id}`);
172
167
  if (!response.ok) {
173
168
  return;
174
169
  }
@@ -108,12 +108,6 @@ function autoFit(scroll=true) {
108
108
  if (scroll) terminal.scrollToBottom();
109
109
  }
110
110
 
111
- function getTokenParam() {
112
- const urlParams = new URLSearchParams(window.location.search);
113
- return urlParams.get('token') ? `?token=${urlParams.get('token')}` : '';
114
- }
115
- const urlToken = getTokenParam();
116
-
117
111
 
118
112
  document.getElementById('launchForm').addEventListener('submit', async (event) => {
119
113
  event.preventDefault();
@@ -122,7 +116,7 @@ document.getElementById('launchForm').addEventListener('submit', async (event) =
122
116
  fitAddon.fit();
123
117
  terminal.clear();
124
118
  try {
125
- const response = await fetch(`/commands${urlToken}`, {
119
+ const response = await fetch(`/commands`, {
126
120
  method: 'POST',
127
121
  headers: {
128
122
  'Content-Type': 'application/json'
@@ -144,12 +138,14 @@ document.getElementById('launchForm').addEventListener('submit', async (event) =
144
138
 
145
139
  async function fetchCommands(hide=false) {
146
140
  try {
147
- const response = await fetch(`/commands${urlToken}`);
141
+ const response = await fetch(`/commands`);
148
142
  if (!response.ok) {
149
143
  document.getElementById('dimmer').style.display = 'block';
150
144
  return;
151
145
  }
152
- const commands = await response.json();
146
+ // Adapt to the new result structure:
147
+ const data = await response.json();
148
+ const commands = data.commands;
153
149
  commands.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
154
150
  const commandsTbody = document.getElementById('commands');
155
151
  commandsTbody.innerHTML = '';
@@ -252,13 +248,13 @@ async function viewOutput(command_id) {
252
248
  outputPercentage.innerText = '100%';
253
249
  adjustOutputHeight();
254
250
  currentCommandId = command_id;
255
- nextOutputLink = `/commands/${command_id}/output${urlToken}`; // updated URL
251
+ nextOutputLink = `/commands/${command_id}/output`;
256
252
  clearInterval(outputInterval);
257
253
  terminal.clear();
258
254
  terminal.reset();
259
255
  fullOutput = '';
260
256
  try {
261
- const response = await fetch(`/commands/${command_id}${urlToken}`);
257
+ const response = await fetch(`/commands/${command_id}`);
262
258
  if (!response.ok) {
263
259
  outputInterval = setInterval(() => fetchOutput(nextOutputLink), 500);
264
260
  }
@@ -289,7 +285,7 @@ async function viewOutput(command_id) {
289
285
  async function openPopup(command_id, event) {
290
286
  event.stopPropagation();
291
287
  event.stopImmediatePropagation();
292
- const popupUrl = `/commands/${command_id}/popup${urlToken}`;
288
+ const popupUrl = `/commands/${command_id}/popup`;
293
289
  window.open(popupUrl, '_blank', 'width=1000,height=600');
294
290
  }
295
291
 
@@ -297,7 +293,7 @@ async function relaunchCommand(command_id, event) {
297
293
  event.stopPropagation();
298
294
  event.stopImmediatePropagation();
299
295
  try {
300
- const response = await fetch(`/commands/${command_id}${urlToken}`);
296
+ const response = await fetch(`/commands/${command_id}`);
301
297
  if (!response.ok) {
302
298
  throw new Error('Failed to fetch command status');
303
299
  }
@@ -308,7 +304,7 @@ async function relaunchCommand(command_id, event) {
308
304
  }
309
305
  fitAddon.fit();
310
306
  terminal.clear();
311
- const relaunchResponse = await fetch(`/commands${urlToken}`, {
307
+ const relaunchResponse = await fetch(`/commands`, {
312
308
  method: 'POST',
313
309
  headers: {
314
310
  'Content-Type': 'application/json'
@@ -336,7 +332,7 @@ async function stopCommand(command_id, event) {
336
332
  event.stopPropagation();
337
333
  event.stopImmediatePropagation();
338
334
  try {
339
- const response = await fetch(`/commands/${command_id}/stop${urlToken}`, {
335
+ const response = await fetch(`/commands/${command_id}/stop`, {
340
336
  method: 'PATCH'
341
337
  });
342
338
  if (!response.ok) {
pywebexec/swagger.yaml CHANGED
@@ -9,6 +9,32 @@ paths:
9
9
  responses:
10
10
  "200":
11
11
  description: "List of all commands status"
12
+ schema:
13
+ type: object
14
+ properties:
15
+ commands:
16
+ type: array
17
+ items:
18
+ type: object
19
+ properties:
20
+ command_id:
21
+ type: string
22
+ command:
23
+ type: string
24
+ status:
25
+ type: string
26
+ start_time:
27
+ type: string
28
+ format: date-time
29
+ end_time:
30
+ type: string
31
+ format: date-time
32
+ exit_code:
33
+ type: integer
34
+ last_output_line:
35
+ type: string
36
+ user:
37
+ type: string
12
38
  post:
13
39
  summary: "Run a command"
14
40
  consumes:
@@ -23,17 +49,31 @@ paths:
23
49
  properties:
24
50
  command:
25
51
  type: string
52
+ # Enum will be added dynamically by the APIs
53
+ default: commandName
26
54
  params:
27
55
  type: array
28
56
  items:
29
57
  type: string
58
+ default: []
30
59
  rows:
31
60
  type: integer
61
+ default: 24
32
62
  cols:
33
63
  type: integer
64
+ default: 125
65
+ required:
66
+ - command
34
67
  responses:
35
68
  "200":
36
69
  description: "Command started"
70
+ schema:
71
+ type: object
72
+ properties:
73
+ command_id:
74
+ type: string
75
+ message:
76
+ type: string
37
77
  /commands/{command_id}:
38
78
  get:
39
79
  summary: "Get command status"
@@ -45,6 +85,40 @@ paths:
45
85
  responses:
46
86
  "200":
47
87
  description: "Command status returned"
88
+ schema:
89
+ type: object
90
+ properties:
91
+ command_id:
92
+ type: string
93
+ command:
94
+ type: string
95
+ params:
96
+ type: array
97
+ items:
98
+ type: string
99
+ status:
100
+ type: string
101
+ start_time:
102
+ type: string
103
+ format: date-time
104
+ end_time:
105
+ type: string
106
+ format: date-time
107
+ exit_code:
108
+ type: integer
109
+ last_output_line:
110
+ type: string
111
+ cols:
112
+ type: integer
113
+ rows:
114
+ type: integer
115
+ user:
116
+ type: string
117
+ from:
118
+ type: string
119
+ pid:
120
+ type: integer
121
+
48
122
  /commands/{command_id}/output:
49
123
  get:
50
124
  summary: "Get command output"
@@ -61,9 +135,35 @@ paths:
61
135
  name: maxsize
62
136
  type: integer
63
137
  default: 10485760
138
+ - in: query
139
+ name: maxlines
140
+ type: integer
141
+ default: 5000
142
+ consumes:
143
+ - application/json
144
+ produces:
145
+ - application/json
146
+ - text/plain
64
147
  responses:
65
148
  "200":
66
149
  description: "Command output returned"
150
+ schema:
151
+ type: object
152
+ properties:
153
+ output:
154
+ type: string
155
+ status:
156
+ type: string
157
+ rows:
158
+ type: integer
159
+ cols:
160
+ type: integer
161
+ links:
162
+ type: object
163
+ properties:
164
+ next:
165
+ type: string
166
+ format: uri
67
167
  /commands/{command_id}/stop:
68
168
  patch:
69
169
  summary: "Stop a running command"
@@ -75,9 +175,35 @@ paths:
75
175
  responses:
76
176
  "200":
77
177
  description: "Command stopped successfully"
178
+ schema:
179
+ type: object
180
+ properties:
181
+ message:
182
+ type: string
78
183
  /executables:
79
184
  get:
80
185
  summary: "List available executable commands"
186
+ produces:
187
+ - application/json
81
188
  responses:
82
189
  "200":
83
- description: "List of executables returned as an array of executable names"
190
+ description: "List of executables returned as an array of executable objects"
191
+ schema:
192
+ type: object
193
+ properties:
194
+ executables:
195
+ type: array
196
+ items:
197
+ type: object
198
+ properties:
199
+ command:
200
+ type: string
201
+ help:
202
+ type: string
203
+ examples:
204
+ application/json:
205
+ executables:
206
+ - command: ls
207
+ help: "List directory contents\nparams:\n <ls_params>"
208
+ - command: cat
209
+ help: "Concatenate and display files\nparams:\n <cat_params>"
@@ -21,6 +21,7 @@
21
21
  </div>
22
22
  <div id="commandList" class="command-list"></div>
23
23
  <input type="text" id="params" name="params" oninput="this.style.width = ((this.value.length + 1) * 8) + 'px';">
24
+ <pre id="paramsHelp"></pre>
24
25
  </div>
25
26
  <button type="submit">Run</button>
26
27
  </form>
@@ -60,6 +61,6 @@
60
61
  <script src="/static/js/xterm/addon-unicode-graphemes.js"></script>
61
62
  <script src="/static/js/xterm/addon-fit.js"></script>
62
63
  <script type="text/javascript" src="/static/js/script.js"></script>
63
- <script type="text/javascript" src="/static/js/commands.js"></script>
64
+ <script type="text/javascript" src="/static/js/executables.js"></script>
64
65
  </body>
65
66
  </html>
pywebexec/version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '1.9.2'
21
- __version_tuple__ = version_tuple = (1, 9, 2)
20
+ __version__ = version = '1.9.4'
21
+ __version_tuple__ = version_tuple = (1, 9, 4)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: pywebexec
3
- Version: 1.9.2
3
+ Version: 1.9.4
4
4
  Summary: Simple Python HTTP Exec Server
5
5
  Home-page: https://github.com/joknarf/pywebexec
6
6
  Author: Franck Jouvanceau
@@ -63,13 +63,13 @@ Requires-Dist: ldap3>=2.9.1
63
63
  Requires-Dist: pyte>=0.8.1
64
64
 
65
65
  [![Pypi version](https://img.shields.io/pypi/v/pywebexec.svg)](https://pypi.org/project/pywebexec/)
66
- ![example](https://github.com/joknarf/pywebexec/actions/workflows/python-publish.yml/badge.svg)
66
+ ![Publish Package](https://github.com/joknarf/pywebexec/actions/workflows/python-publish.yml/badge.svg)
67
67
  [![Licence](https://img.shields.io/badge/licence-MIT-blue.svg)](https://shields.io/)
68
68
  [![PyPI Downloads](https://static.pepy.tech/badge/pywebexec)](https://pepy.tech/projects/pywebexec)
69
69
  [![Python versions](https://img.shields.io/badge/python-3.6+-blue.svg)](https://shields.io/)
70
70
 
71
71
  # pywebexec
72
- Simple Python HTTP(S) API/Web Command Launcher and Terminal sharing
72
+ Simple Python HTTP(S) API/Web Server Command Launcher and Terminal sharing
73
73
 
74
74
  ## Install
75
75
  ```
@@ -79,6 +79,8 @@ $ pip install pywebexec
79
79
  ## Quick start
80
80
 
81
81
  * share terminal
82
+ * start http server and spawn a new terminal shared on 0.0.0.0 port 8080 (defaults)
83
+ * exiting terminal stops server/share
82
84
  ```shell
83
85
  $ pywebexec shareterm
84
86
  ```
@@ -100,6 +102,7 @@ all commands output / statuses are available in the executables directory in sub
100
102
  ## features
101
103
 
102
104
  * Serve executables in a directory
105
+ * full API driven with dynamic swagger UI
103
106
  * Launch commands with params from web browser or API call
104
107
  * multiple share terminal output
105
108
  * Follow live output
@@ -190,20 +193,29 @@ $ pywebexec stop
190
193
  ## Launch command through API
191
194
 
192
195
  ```shell
193
- $ curl http://myhost:8080/run_command -H 'Content-Type: application/json' -X POST -d '{ "command":"myscript", "params":["param1", ...]}'
194
- $ curl http://myhost:8080/command_status/<command_id>
195
- $ curl http://myhost:8080/command_output/<command_id> -H "Accept: text/plain"
196
+ $ curl http://myhost:8080/commands/myscript -H 'Content-Type: application/json' -X POST -d '{"params":["param1", ...]}'
197
+ $ curl http://myhost:8080/commands/<command_id>
198
+ $ curl http://myhost:8080/commands/<command_id>/output -H "Accept: text/plain"
196
199
  ```
197
200
 
201
+ ## Add help to commands
202
+
203
+ For each exposed command, you can add a help message by creating a file named `<command>.help` in the same directory as the command.
204
+ The help message is displayed:
205
+ * in the web interface as tooltip when focused on param input field,
206
+ * in the response when calling the API `/executables`
207
+ * in the swagger-ui in the `/commands/<command>` route.
208
+
198
209
  ## API reference
199
210
 
200
211
 
201
212
  | method | route | params/payload | returns
202
213
  |-----------|-----------------------------|--------------------|---------------------|
203
- | GET | /executables | | array of str |
204
- | GET | /commands | | array of<br>command_id: uuid<br>command: str<br>start_time: isotime<br>end_time: isotime<br>status: str<br>exit_code: int<br>last_output_line: str |
214
+ | GET | /executables | | executables: [<br>&nbsp;&nbsp;{command: str,help: str},<br>] |
215
+ | GET | /commands | | commands: [<br>&nbsp;&nbsp;{<br>&nbsp;&nbsp;&nbsp;&nbsp;command_id: uuid<br>&nbsp;&nbsp;&nbsp;&nbsp;command: str<br>&nbsp;&nbsp;&nbsp;&nbsp;start_time: isotime<br>&nbsp;&nbsp;&nbsp;&nbsp;end_time: isotime<br>&nbsp;&nbsp;&nbsp;&nbsp;status: str<br>&nbsp;&nbsp;&nbsp;&nbsp;exit_code: int<br>&nbsp;&nbsp;&nbsp;&nbsp;last_output_line: str<br>&nbsp;&nbsp;},<br>] |
205
216
  | GET | /commands/{id} | | command_id: uuid<br>command: str<br>params: array[str]<br>start_time: isotime<br>end_time: isotime<br>status: str<br>exit_code: int<br>last_output_line: str |
206
217
  | GET | /commands/{id}/output | offset: int | output: str<br>status: str<br>links: { next: str } |
207
218
  | GET | /commands/{id}/output_raw | offset: int | output: stream raw output until end of command<br>curl -Ns http://srv/commands/{id}/output_raw|
208
219
  | POST | /commands | command: str<br>params: array[str]<br>rows: int<br>cols: int | command_id: uuid<br>message: str |
220
+ | POST | /commands/{cmd} | params: array[str]<br>rows: int<br>cols: int | command_id: uuid<br>message: str |
209
221
  | PATCH | /commands/{id}/stop | | message: str |
@@ -1,9 +1,9 @@
1
1
  pywebexec/__init__.py,sha256=197fHJy0UDBwTTpGCGortZRr-w2kTaD7MxqdbVmTEi0,61
2
2
  pywebexec/host_ip.py,sha256=Ud_HTflWVQ8789aoQ2RZdT1wGI-ccvrwSWGz_c7T3TI,1241
3
- pywebexec/pywebexec.py,sha256=uGOdA0nQ4Na9F12vZYXVBr11Uk9XoYuhdM8lJxEUC0Y,33679
4
- pywebexec/swagger.yaml,sha256=-uafngZxQFHLdnWY-9SFCdgotO5wynFN2sTEyuBpQ_Q,1998
5
- pywebexec/version.py,sha256=Bx58trLhK_vl5EzDfK18POHZa_BoHwqv52T5hR9tbaA,511
6
- pywebexec/static/css/style.css,sha256=SuOU_USRh8BiAxEJ1LDYIx3asf3lETu_evWzA54gsBo,8145
3
+ pywebexec/pywebexec.py,sha256=ghCPH_SXooljVK3le_SOgWoT-cz5DMFmJZMNwWaRwDQ,37918
4
+ pywebexec/swagger.yaml,sha256=w5Jl1S9yKhXZ5oaUunX8DJBToUGb9vsxvJE-K4KMNNA,5504
5
+ pywebexec/version.py,sha256=x_dqnWSuZ5m1DFc2qnANNI5KdLpU51P41kIYXxibols,511
6
+ pywebexec/static/css/style.css,sha256=p06KbEMfowCC_WoWaJf5PwxEt2I0QR2aWX8oP1Y06Mo,8525
7
7
  pywebexec/static/css/xterm.css,sha256=uo5phWaUiJgcz0DAzv46uoByLLbJLeetYosL1xf68rY,5559
8
8
  pywebexec/static/fonts/CommitMonoNerdFontMono-Regular.ttf,sha256=v6nZdSx5cs_TIic8Fujrjzg9u9glWjorDIr7RlwNceM,2370228
9
9
  pywebexec/static/fonts/LICENSE,sha256=gsBdbFPfoMkCWYXBnjcYEAILdO0sYdUdNw8qirJQbVI,4395
@@ -23,9 +23,9 @@ pywebexec/static/images/popup.svg,sha256=0Bl9A_v5cBsMPn6FnOlVWlAQKgd2zqiWQbhjcL9
23
23
  pywebexec/static/images/resume.svg,sha256=99LP1Ya2JXakRCO9kW8JMuT_4a_CannF65EiuwtvK4A,607
24
24
  pywebexec/static/images/running.svg,sha256=fBCYwYb2O9K4N3waC2nURP25NRwZlqR4PbDZy6JQMww,610
25
25
  pywebexec/static/images/success.svg,sha256=NVwezvVMplt46ElW798vqGfrL21Mw_DWHUp_qiD_FU8,489
26
- pywebexec/static/js/commands.js,sha256=TmfcauQlfIeAeC8pwQvKspc4PA_VYLbPTnVCDVBZ87I,8420
27
- pywebexec/static/js/popup.js,sha256=2Ku0h8vtM4hw4wyd_agKgf8vMFf-hQBKueREV8Y0Sio,9252
28
- pywebexec/static/js/script.js,sha256=D7pPWiLv3ki_V4o4k0bE1wdBQrx0I2W39EmEJu-T_10,18162
26
+ pywebexec/static/js/executables.js,sha256=jwb5TcbWmEyhbfp5Xc3re_zr3hvOV-2u6zPzObQnn3g,9542
27
+ pywebexec/static/js/popup.js,sha256=0fr3pp4j9D2fXEVnHyQrx2bPWFHfgbb336dbewgH1d8,9023
28
+ pywebexec/static/js/script.js,sha256=EpDwM1CyvHgsfkMSAkYmS9nU0sKOEXpQ6L3xhDIabEY,17933
29
29
  pywebexec/static/js/xterm/LICENSE,sha256=EU1P4eXTull-_T9I80VuwnJXubB-zLzUl3xpEYj2T1M,1083
30
30
  pywebexec/static/js/xterm/addon-canvas.js,sha256=ez6QTVvsmLVNJmdJlM-ZQ5bErwlxAQ_9DUmDIptl2TM,94607
31
31
  pywebexec/static/js/xterm/addon-canvas.js.map,sha256=ECBA4B-BqUpdFeRzlsEWLSQnudnhLP-yPQJ8_hKquMo,379537
@@ -38,11 +38,11 @@ pywebexec/static/js/xterm/addon-unicode11.js.map,sha256=paDj5KKtTIUGedQn2x7CaUTD
38
38
  pywebexec/static/js/xterm/xterm.js,sha256=H5kaw7Syg-v5bmCuI6AKUnZd06Lkb6b92p8aqwMvdJU,289441
39
39
  pywebexec/static/js/xterm/xterm.js.map,sha256=Y7O2Pb-fIS7Z8AC1D5s04_aiW_Jf1f4mCfN0U_OI6Zw,1118392
40
40
  pywebexec/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
- pywebexec/templates/index.html,sha256=VLcuC0RUkwefDugXWcXsjd5C3owKk5wCJoYIo48xbgk,3106
41
+ pywebexec/templates/index.html,sha256=hx27of3TqTYWIdRQ0Jl1Z9WPsRG7I6rmZTf4zNC_9Xw,3149
42
42
  pywebexec/templates/popup.html,sha256=3kpMccKD_OLLhJ4Y9KRw6Ny8wQWjVaRrUfV9y5-bDiQ,1580
43
- pywebexec-1.9.2.dist-info/LICENSE,sha256=gRJf0JPT_wsZJsUGlWPTS8Vypfl9vQ1qjp6sNbKykuA,1064
44
- pywebexec-1.9.2.dist-info/METADATA,sha256=-y8vqPEpfH7uCerUz2wdRlB2GTpJThP0HmFNEfrvKKo,8154
45
- pywebexec-1.9.2.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
46
- pywebexec-1.9.2.dist-info/entry_points.txt,sha256=l52GBkPCXRkmlHfEyoVauyfBdg8o-CAtC8qQpOIjJK0,55
47
- pywebexec-1.9.2.dist-info/top_level.txt,sha256=vHoHyzngrfGdm_nM7Xn_5iLmaCrf10XO1EhldgNLEQ8,10
48
- pywebexec-1.9.2.dist-info/RECORD,,
43
+ pywebexec-1.9.4.dist-info/LICENSE,sha256=gRJf0JPT_wsZJsUGlWPTS8Vypfl9vQ1qjp6sNbKykuA,1064
44
+ pywebexec-1.9.4.dist-info/METADATA,sha256=sZuqjtYkJxHczyq4KZETEo_vDcN8ALHEVq3AG17coR0,9074
45
+ pywebexec-1.9.4.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
46
+ pywebexec-1.9.4.dist-info/entry_points.txt,sha256=l52GBkPCXRkmlHfEyoVauyfBdg8o-CAtC8qQpOIjJK0,55
47
+ pywebexec-1.9.4.dist-info/top_level.txt,sha256=vHoHyzngrfGdm_nM7Xn_5iLmaCrf10XO1EhldgNLEQ8,10
48
+ pywebexec-1.9.4.dist-info/RECORD,,