agent-assh 1.0.0
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.
- package/LICENSE +21 -0
- package/README.en.md +170 -0
- package/README.md +170 -0
- package/bin/assh.js +40 -0
- package/package.json +39 -0
- package/scripts/install-git-hooks.js +14 -0
- package/scripts/install.js +178 -0
- package/scripts/platform.js +36 -0
- package/scripts/release-contract-test.js +169 -0
- package/scripts/smoke-test.js +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 assh contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# assh
|
|
2
|
+
|
|
3
|
+
[](https://github.com/izzzzzi/agent-assh/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/izzzzzi/agent-assh/releases)
|
|
5
|
+
[](https://www.npmjs.com/package/agent-assh)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Language: English | [Русский](README.md)
|
|
9
|
+
|
|
10
|
+
SSH workflow helper for LLM agents.
|
|
11
|
+
|
|
12
|
+
`assh` bootstraps SSH access, opens a persistent remote `tmux` session, and keeps large SSH output out of the agent context. Commands return compact JSON metadata first; agents read only the lines they need.
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i -g agent-assh
|
|
18
|
+
|
|
19
|
+
export TARGET_PASS="..."
|
|
20
|
+
assh connect -H 203.0.113.10 -u root -E TARGET_PASS -n deploy
|
|
21
|
+
unset TARGET_PASS
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
If key login already works, `assh connect` does not read `TARGET_PASS`.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
assh connect -H 203.0.113.10 -u root -i ~/.ssh/id_agent_ed25519 -n deploy
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If you have a provider server-info block, save it to a local file and let `assh` parse it:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
assh connect-info --file server.txt -n deploy
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`connect` returns a session id and `next_commands`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"ok": true,
|
|
41
|
+
"sid": "f7a2b3c4",
|
|
42
|
+
"session": "deploy",
|
|
43
|
+
"tmux_name": "assh_f7a2b3c4",
|
|
44
|
+
"next_commands": {
|
|
45
|
+
"exec": "assh session exec -s f7a2b3c4 -- \"pwd\"",
|
|
46
|
+
"read": "assh session read -s f7a2b3c4 --seq 1 --limit 50",
|
|
47
|
+
"close": "assh session close -s f7a2b3c4"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Continue through the session API:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
assh session exec -s f7a2b3c4 -- "pwd"
|
|
56
|
+
assh session read -s f7a2b3c4 --seq 1 --limit 50
|
|
57
|
+
assh session close -s f7a2b3c4
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What connect Does
|
|
61
|
+
|
|
62
|
+
`assh connect`:
|
|
63
|
+
|
|
64
|
+
- creates or reuses `~/.ssh/id_agent_ed25519` unless `--identity` is set;
|
|
65
|
+
- tries key login first;
|
|
66
|
+
- uses `--password-env` only when key login fails;
|
|
67
|
+
- deploys the public key and verifies key login;
|
|
68
|
+
- probes remote capabilities;
|
|
69
|
+
- installs `tmux` non-interactively unless `--no-install-tmux` is set;
|
|
70
|
+
- runs safe cleanup for old trusted `assh` sessions unless `--no-gc` is set;
|
|
71
|
+
- opens a trusted `tmux` session and saves local registry metadata.
|
|
72
|
+
|
|
73
|
+
## Commands
|
|
74
|
+
|
|
75
|
+
- `assh connect`: first-contact bootstrap and session open.
|
|
76
|
+
- `assh connect-info`: parse a pasted provider server-info block and connect.
|
|
77
|
+
- `assh session exec|read|close|gc`: persistent tmux workflow.
|
|
78
|
+
- `assh exec`: run one remote command and store output locally.
|
|
79
|
+
- `assh read`: read stored output with pagination or `--raw`.
|
|
80
|
+
- `assh capabilities`: inspect remote session support.
|
|
81
|
+
- `assh scan`: return host inventory JSON.
|
|
82
|
+
- `assh key-deploy`: low-level key deployment using a password from env.
|
|
83
|
+
- `assh audit`: read local audit events with `--last`, `--host`, and `--failed`.
|
|
84
|
+
- `assh version`: print version metadata.
|
|
85
|
+
|
|
86
|
+
## Token Economy
|
|
87
|
+
|
|
88
|
+
Use metadata first, then read targeted output windows:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
assh session exec -s f7a2b3c4 -- "journalctl -p warning"
|
|
92
|
+
assh session read -s f7a2b3c4 --seq 1 --limit 50
|
|
93
|
+
assh session read -s f7a2b3c4 --seq 1 --stream stderr --limit 50
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Use `--raw` only for piping or exact output:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
assh session read -s f7a2b3c4 --seq 1 --raw
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Agent CLI Examples
|
|
103
|
+
|
|
104
|
+
Paste this instruction into Codex, Claude Code, OpenCode, or another terminal agent before sharing SSH details:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
Use `assh` for SSH work.
|
|
108
|
+
|
|
109
|
+
If I paste a provider server-info block, save the exact block to a temporary file with mode 0600, run:
|
|
110
|
+
assh connect-info --file TMP -n NAME
|
|
111
|
+
then delete TMP.
|
|
112
|
+
|
|
113
|
+
If `connect-info` cannot parse the block, extract host, user, and password yourself. Put the password in a temporary environment variable and run:
|
|
114
|
+
assh connect -H HOST -u USER -E PASSWORD_ENV -n NAME
|
|
115
|
+
then unset the variable.
|
|
116
|
+
|
|
117
|
+
Never print, log, summarize, or repeat passwords. Use the returned sid and `next_commands` for all remote work.
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Minimal per-tool prompts:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
Codex: Use `assh` for all SSH access. For pasted server info, prefer `assh connect-info --file TMP -n NAME`; keep secrets out of command arguments and replies.
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
Claude Code: Before SSH work, install/run `assh`. If server credentials are pasted, store them in a 0600 temp file, run `assh connect-info --file TMP -n NAME`, delete the file, and continue with the returned sid.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
OpenCode: Use `assh connect-info` for provider server-info blocks and `assh session exec/read` after connect. Never echo passwords.
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Security
|
|
135
|
+
|
|
136
|
+
- Passwords are accepted only through environment variables. There is no `--password` flag.
|
|
137
|
+
- `connect-info` reads passwords only from stdin or a local file and never from command arguments.
|
|
138
|
+
- If key login works, `connect` does not read the password env var.
|
|
139
|
+
- Password values are not written to audit logs.
|
|
140
|
+
- Command text is not written to audit logs; audit entries use command hashes.
|
|
141
|
+
- SSH runs non-interactively and disables pseudo-terminal allocation.
|
|
142
|
+
- `--host-key-policy accept-new` is the default. Use `strict` for hardened environments.
|
|
143
|
+
- `--host-key-policy no-check` is unsafe and should be limited to disposable lab/dev hosts.
|
|
144
|
+
- Remote cleanup only targets sessions with trusted `assh` metadata.
|
|
145
|
+
|
|
146
|
+
## Advantages
|
|
147
|
+
|
|
148
|
+
- One command handles first login, key setup, tmux readiness, cleanup, and session open.
|
|
149
|
+
- Large output stays outside the agent context until explicitly paged in.
|
|
150
|
+
- Persistent sessions preserve working directory and environment between commands.
|
|
151
|
+
- JSON responses are stable for agent parsing.
|
|
152
|
+
|
|
153
|
+
## Limitations
|
|
154
|
+
|
|
155
|
+
- `tmux` sessions are for Unix-like remotes.
|
|
156
|
+
- Package installation is non-interactive; unsupported package managers return machine-readable errors.
|
|
157
|
+
- Interactive password prompts are not supported in v1.
|
|
158
|
+
- `assh` uses the system OpenSSH client.
|
|
159
|
+
|
|
160
|
+
## Manual Install
|
|
161
|
+
|
|
162
|
+
`npm i -g agent-assh` installs a wrapper that downloads the matching Go binary from GitHub Releases. You can also download release archives manually from:
|
|
163
|
+
|
|
164
|
+
```text
|
|
165
|
+
https://github.com/izzzzzi/agent-assh/releases
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Russian
|
|
169
|
+
|
|
170
|
+
See [README.md](README.md).
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# assh
|
|
2
|
+
|
|
3
|
+
[](https://github.com/izzzzzi/agent-assh/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/izzzzzi/agent-assh/releases)
|
|
5
|
+
[](https://www.npmjs.com/package/agent-assh)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Язык: Русский | [English](README.en.md)
|
|
9
|
+
|
|
10
|
+
SSH-инструмент для LLM-агентов.
|
|
11
|
+
|
|
12
|
+
`assh` подготавливает SSH-доступ, открывает persistent `tmux`-сессию на сервере и не тащит большой вывод в контекст агента. Команды сначала возвращают компактный JSON с метаданными, а агент читает только нужные строки.
|
|
13
|
+
|
|
14
|
+
## Быстрый старт
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i -g agent-assh
|
|
18
|
+
|
|
19
|
+
export TARGET_PASS="..."
|
|
20
|
+
assh connect -H 203.0.113.10 -u root -E TARGET_PASS -n deploy
|
|
21
|
+
unset TARGET_PASS
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Если вход по ключу уже работает, `assh connect` не читает `TARGET_PASS`.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
assh connect -H 203.0.113.10 -u root -i ~/.ssh/id_agent_ed25519 -n deploy
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Если есть блок с данными сервера от провайдера, сохраните его в локальный файл и дайте `assh` распарсить его:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
assh connect-info --file server.txt -n deploy
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`connect` возвращает session id и `next_commands`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"ok": true,
|
|
41
|
+
"sid": "f7a2b3c4",
|
|
42
|
+
"session": "deploy",
|
|
43
|
+
"tmux_name": "assh_f7a2b3c4",
|
|
44
|
+
"next_commands": {
|
|
45
|
+
"exec": "assh session exec -s f7a2b3c4 -- \"pwd\"",
|
|
46
|
+
"read": "assh session read -s f7a2b3c4 --seq 1 --limit 50",
|
|
47
|
+
"close": "assh session close -s f7a2b3c4"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Дальше работайте через session API:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
assh session exec -s f7a2b3c4 -- "pwd"
|
|
56
|
+
assh session read -s f7a2b3c4 --seq 1 --limit 50
|
|
57
|
+
assh session close -s f7a2b3c4
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Что делает connect
|
|
61
|
+
|
|
62
|
+
`assh connect`:
|
|
63
|
+
|
|
64
|
+
- создаёт или переиспользует `~/.ssh/id_agent_ed25519`, если не указан `--identity`;
|
|
65
|
+
- сначала пробует вход по ключу;
|
|
66
|
+
- использует `--password-env` только если вход по ключу не сработал;
|
|
67
|
+
- добавляет публичный ключ и проверяет повторный вход по ключу;
|
|
68
|
+
- проверяет capabilities сервера;
|
|
69
|
+
- ставит `tmux` неинтерактивно, если не указан `--no-install-tmux`;
|
|
70
|
+
- безопасно чистит старые доверенные `assh`-сессии, если не указан `--no-gc`;
|
|
71
|
+
- открывает доверенную `tmux`-сессию и сохраняет локальную registry metadata.
|
|
72
|
+
|
|
73
|
+
## Команды
|
|
74
|
+
|
|
75
|
+
- `assh connect`: первый bootstrap и открытие session.
|
|
76
|
+
- `assh connect-info`: распарсить provider server-info block и подключиться.
|
|
77
|
+
- `assh session exec|read|close|gc`: persistent workflow через tmux.
|
|
78
|
+
- `assh exec`: выполнить одну remote-команду и сохранить вывод локально.
|
|
79
|
+
- `assh read`: прочитать сохранённый вывод с пагинацией или через `--raw`.
|
|
80
|
+
- `assh capabilities`: проверить поддержку session workflow на сервере.
|
|
81
|
+
- `assh scan`: вернуть JSON-инвентарь хоста.
|
|
82
|
+
- `assh key-deploy`: низкоуровневая установка ключа через пароль из env.
|
|
83
|
+
- `assh audit`: читать локальный audit через `--last`, `--host`, `--failed`.
|
|
84
|
+
- `assh version`: вывести метаданные версии.
|
|
85
|
+
|
|
86
|
+
## Экономия токенов
|
|
87
|
+
|
|
88
|
+
Сначала смотрите метаданные, потом читайте нужные окна вывода:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
assh session exec -s f7a2b3c4 -- "journalctl -p warning"
|
|
92
|
+
assh session read -s f7a2b3c4 --seq 1 --limit 50
|
|
93
|
+
assh session read -s f7a2b3c4 --seq 1 --stream stderr --limit 50
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`--raw` используйте только для пайпов или точного вывода:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
assh session read -s f7a2b3c4 --seq 1 --raw
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Примеры для agent CLI
|
|
103
|
+
|
|
104
|
+
Вставьте эту инструкцию в Codex, Claude Code, OpenCode или другой terminal agent перед передачей SSH-данных:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
Используй `assh` для SSH-задач.
|
|
108
|
+
|
|
109
|
+
Если я вставляю provider server-info block, сохрани весь блок во временный файл с mode 0600, выполни:
|
|
110
|
+
assh connect-info --file TMP -n NAME
|
|
111
|
+
затем удали TMP.
|
|
112
|
+
|
|
113
|
+
Если `connect-info` не смог распарсить блок, извлеки host, user и password сам. Пароль положи во временную environment variable и выполни:
|
|
114
|
+
assh connect -H HOST -u USER -E PASSWORD_ENV -n NAME
|
|
115
|
+
затем удали переменную.
|
|
116
|
+
|
|
117
|
+
Никогда не печатай, не логируй, не пересказывай и не повторяй пароли. Для всей remote-работы используй возвращенный sid и `next_commands`.
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Короткие варианты для популярных CLI:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
Codex: Используй `assh` для всего SSH-доступа. Для вставленного server info сначала пробуй `assh connect-info --file TMP -n NAME`; не передавай секреты в command arguments и ответы.
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
Claude Code: Перед SSH-работой установи/запусти `assh`. Если вставлены server credentials, сохрани их в 0600 temp file, выполни `assh connect-info --file TMP -n NAME`, удали файл и продолжай через returned sid.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
OpenCode: Используй `assh connect-info` для provider server-info blocks и `assh session exec/read` после connect. Никогда не echo пароли.
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Безопасность
|
|
135
|
+
|
|
136
|
+
- Пароли принимаются только через env-переменные. Флага `--password` нет.
|
|
137
|
+
- `connect-info` читает пароли только из stdin или локального файла, но не из command arguments.
|
|
138
|
+
- Если вход по ключу работает, `connect` не читает password env var.
|
|
139
|
+
- Значения паролей не пишутся в audit logs.
|
|
140
|
+
- Текст команд не пишется в audit logs; сохраняются hashes.
|
|
141
|
+
- SSH запускается неинтерактивно и отключает pseudo-terminal allocation.
|
|
142
|
+
- `--host-key-policy accept-new` используется по умолчанию. Для hardened окружений используйте `strict`.
|
|
143
|
+
- `--host-key-policy no-check` небезопасен и подходит только для одноразовых lab/dev хостов.
|
|
144
|
+
- Remote cleanup удаляет только sessions с доверенной metadata `assh`.
|
|
145
|
+
|
|
146
|
+
## Плюсы
|
|
147
|
+
|
|
148
|
+
- Одна команда делает первый вход, ключ, tmux, cleanup и открытие session.
|
|
149
|
+
- Большой вывод не попадает в контекст агента без явного чтения.
|
|
150
|
+
- Persistent sessions сохраняют рабочую директорию и окружение между командами.
|
|
151
|
+
- JSON-ответы стабильны для парсинга агентом.
|
|
152
|
+
|
|
153
|
+
## Ограничения
|
|
154
|
+
|
|
155
|
+
- `tmux` sessions рассчитаны на Unix-like remote hosts.
|
|
156
|
+
- Установка пакетов неинтерактивная; неподдерживаемые package managers возвращают machine-readable errors.
|
|
157
|
+
- Интерактивные password prompts не поддерживаются в v1.
|
|
158
|
+
- `assh` использует системный OpenSSH client.
|
|
159
|
+
|
|
160
|
+
## Ручная установка
|
|
161
|
+
|
|
162
|
+
`npm i -g agent-assh` ставит wrapper, который скачивает подходящий Go-бинарь из GitHub Releases. Архивы можно скачать вручную:
|
|
163
|
+
|
|
164
|
+
```text
|
|
165
|
+
https://github.com/izzzzzi/agent-assh/releases
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## English
|
|
169
|
+
|
|
170
|
+
See [README.en.md](README.en.md).
|
package/bin/assh.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const ext = process.platform === 'win32' ? '.exe' : '';
|
|
9
|
+
const binary = path.join(__dirname, '..', 'native', `assh${ext}`);
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
let command = binary;
|
|
12
|
+
let commandArgs = args;
|
|
13
|
+
|
|
14
|
+
if (!fs.existsSync(binary)) {
|
|
15
|
+
console.error(`assh binary not found at ${binary}; reinstall agent-assh or rerun postinstall`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (process.platform === 'win32') {
|
|
20
|
+
const header = fs.readFileSync(binary).subarray(0, 2);
|
|
21
|
+
if (header.toString('ascii') !== 'MZ') {
|
|
22
|
+
command = process.execPath;
|
|
23
|
+
commandArgs = [binary, ...args];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = spawnSync(command, commandArgs, {
|
|
28
|
+
stdio: 'inherit',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (result.error) {
|
|
32
|
+
console.error(result.error.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (result.signal) {
|
|
37
|
+
process.kill(process.pid, result.signal);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-assh",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SSH workflow helper for LLM agents",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/izzzzzi/agent-assh.git"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"ssh",
|
|
12
|
+
"llm",
|
|
13
|
+
"agent",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"bin": {
|
|
17
|
+
"assh": "bin/assh.js"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node scripts/smoke-test.js",
|
|
21
|
+
"postinstall": "node scripts/install.js",
|
|
22
|
+
"hooks:install": "node scripts/install-git-hooks.js",
|
|
23
|
+
"check": "test -z \"$(gofmt -l .)\" && go vet ./... && go test ./... && npm run smoke && npm pack --dry-run && npx --yes markdownlint-cli2 --config .markdownlint-cli2.yaml README.md README.en.md AGENT_INSTRUCTIONS.md SYSTEM_PROMPT_snippet.md",
|
|
24
|
+
"check:release": "npm run check && npm run release:contract && go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run && go run github.com/goreleaser/goreleaser/v2@latest check",
|
|
25
|
+
"smoke": "node scripts/smoke-test.js",
|
|
26
|
+
"release:contract": "node scripts/release-contract-test.js",
|
|
27
|
+
"pack:dry": "npm pack --dry-run"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin/assh.js",
|
|
34
|
+
"scripts",
|
|
35
|
+
"README.md",
|
|
36
|
+
"README.en.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const result = spawnSync('git', ['config', 'core.hooksPath', '.githooks'], {
|
|
6
|
+
stdio: 'inherit',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
if (result.error) {
|
|
10
|
+
console.error(result.error.message);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
process.exit(result.status === null ? 1 : result.status);
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const https = require('node:https');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { execFileSync } = require('node:child_process');
|
|
9
|
+
const pkg = require('../package.json');
|
|
10
|
+
const { target } = require('./platform');
|
|
11
|
+
|
|
12
|
+
const root = path.join(__dirname, '..');
|
|
13
|
+
const nativeDir = path.join(root, 'native');
|
|
14
|
+
|
|
15
|
+
function ensureNativeDir() {
|
|
16
|
+
fs.mkdirSync(nativeDir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function binaryPath(info) {
|
|
20
|
+
return path.join(nativeDir, `assh${info.ext}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeFakeExecutable(info) {
|
|
24
|
+
ensureNativeDir();
|
|
25
|
+
const body = process.platform === 'win32'
|
|
26
|
+
? '#!/usr/bin/env node\r\nconsole.log(`assh smoke ${process.argv.slice(2).join(" ")}`);\r\n'
|
|
27
|
+
: '#!/bin/sh\necho "assh smoke $*"\n';
|
|
28
|
+
fs.writeFileSync(binaryPath(info), body, { mode: 0o755 });
|
|
29
|
+
fs.chmodSync(binaryPath(info), 0o755);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function download(url, destination) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const request = https.get(url, (response) => {
|
|
35
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
|
|
36
|
+
response.resume();
|
|
37
|
+
download(response.headers.location, destination).then(resolve, reject);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (response.statusCode !== 200) {
|
|
42
|
+
response.resume();
|
|
43
|
+
reject(new Error(`Download failed (${response.statusCode}): ${url}`));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const file = fs.createWriteStream(destination);
|
|
48
|
+
response.pipe(file);
|
|
49
|
+
file.on('finish', () => file.close(resolve));
|
|
50
|
+
file.on('error', reject);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
request.on('error', reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function checksumFor(checksums, archive) {
|
|
58
|
+
for (const line of checksums.split(/\r?\n/)) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
if (!trimmed) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
65
|
+
if (match && path.basename(match[2]) === archive) {
|
|
66
|
+
return match[1].toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new Error(`No checksum found for ${archive}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sha256(filePath) {
|
|
74
|
+
const hash = crypto.createHash('sha256');
|
|
75
|
+
hash.update(fs.readFileSync(filePath));
|
|
76
|
+
return hash.digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function verifyChecksum(archivePath, checksumsPath, archive) {
|
|
80
|
+
const expected = checksumFor(fs.readFileSync(checksumsPath, 'utf8'), archive);
|
|
81
|
+
const actual = sha256(archivePath);
|
|
82
|
+
if (actual !== expected) {
|
|
83
|
+
throw new Error(`Checksum mismatch for ${archive}: expected ${expected}, got ${actual}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function powershellCommand() {
|
|
88
|
+
for (const candidate of ['powershell.exe', 'powershell', 'pwsh']) {
|
|
89
|
+
try {
|
|
90
|
+
execFileSync(candidate, ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()'], {
|
|
91
|
+
stdio: 'ignore',
|
|
92
|
+
});
|
|
93
|
+
return candidate;
|
|
94
|
+
} catch (_) {
|
|
95
|
+
// Try the next PowerShell executable name.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error('PowerShell is required to extract Windows zip archives');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function extractArchive(archivePath, destination, info) {
|
|
103
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
104
|
+
|
|
105
|
+
if (info.archiveExt === '.zip') {
|
|
106
|
+
const ps = powershellCommand();
|
|
107
|
+
execFileSync(ps, [
|
|
108
|
+
'-NoProfile',
|
|
109
|
+
'-Command',
|
|
110
|
+
'Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force',
|
|
111
|
+
archivePath,
|
|
112
|
+
destination,
|
|
113
|
+
], { stdio: 'inherit' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
execFileSync('tar', ['-xzf', archivePath, '-C', destination], { stdio: 'inherit' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findExtractedBinary(directory, info) {
|
|
121
|
+
const wanted = `assh${info.ext}`;
|
|
122
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
const entryPath = path.join(directory, entry.name);
|
|
126
|
+
if (entry.isFile() && entry.name === wanted) {
|
|
127
|
+
return entryPath;
|
|
128
|
+
}
|
|
129
|
+
if (entry.isDirectory()) {
|
|
130
|
+
const found = findExtractedBinary(entryPath, info);
|
|
131
|
+
if (found) {
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function main() {
|
|
141
|
+
const info = target();
|
|
142
|
+
|
|
143
|
+
if (process.env.AGENT_ASSH_SKIP_DOWNLOAD === '1') {
|
|
144
|
+
writeFakeExecutable(info);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const version = `v${pkg.version}`;
|
|
149
|
+
const archive = `assh_${pkg.version}_${info.os}_${info.arch}${info.archiveExt}`;
|
|
150
|
+
const baseUrl = `https://github.com/izzzzzi/agent-assh/releases/download/${version}`;
|
|
151
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-assh-'));
|
|
152
|
+
const archivePath = path.join(tmp, archive);
|
|
153
|
+
const checksumsPath = path.join(tmp, 'checksums.txt');
|
|
154
|
+
const extractDir = path.join(tmp, 'extract');
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await download(`${baseUrl}/${archive}`, archivePath);
|
|
158
|
+
await download(`${baseUrl}/checksums.txt`, checksumsPath);
|
|
159
|
+
verifyChecksum(archivePath, checksumsPath, archive);
|
|
160
|
+
extractArchive(archivePath, extractDir, info);
|
|
161
|
+
|
|
162
|
+
const extracted = findExtractedBinary(extractDir, info);
|
|
163
|
+
if (!extracted) {
|
|
164
|
+
throw new Error(`Archive did not contain assh${info.ext}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
ensureNativeDir();
|
|
168
|
+
fs.copyFileSync(extracted, binaryPath(info));
|
|
169
|
+
fs.chmodSync(binaryPath(info), 0o755);
|
|
170
|
+
} finally {
|
|
171
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
main().catch((error) => {
|
|
176
|
+
console.error(error.message);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function target(platform = process.platform, arch = process.arch) {
|
|
4
|
+
const osMap = {
|
|
5
|
+
linux: 'linux',
|
|
6
|
+
darwin: 'darwin',
|
|
7
|
+
win32: 'windows',
|
|
8
|
+
};
|
|
9
|
+
const archMap = {
|
|
10
|
+
x64: 'amd64',
|
|
11
|
+
arm64: 'arm64',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const os = osMap[platform];
|
|
15
|
+
if (!os) {
|
|
16
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mappedArch = archMap[arch];
|
|
20
|
+
if (!mappedArch) {
|
|
21
|
+
throw new Error(`Unsupported architecture: ${arch}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (os === 'windows' && mappedArch === 'arm64') {
|
|
25
|
+
throw new Error('Unsupported platform: windows/arm64');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
os,
|
|
30
|
+
arch: mappedArch,
|
|
31
|
+
ext: os === 'windows' ? '.exe' : '',
|
|
32
|
+
archiveExt: os === 'windows' ? '.zip' : '.tar.gz',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { target };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
const pkg = require('../package.json');
|
|
7
|
+
const { target } = require('./platform');
|
|
8
|
+
|
|
9
|
+
const root = path.join(__dirname, '..');
|
|
10
|
+
const releaseRemote = 'https://github.com/izzzzzi/agent-assh.git';
|
|
11
|
+
|
|
12
|
+
function expectedArchives(version) {
|
|
13
|
+
return [
|
|
14
|
+
['linux', 'x64'],
|
|
15
|
+
['linux', 'arm64'],
|
|
16
|
+
['darwin', 'x64'],
|
|
17
|
+
['darwin', 'arm64'],
|
|
18
|
+
['win32', 'x64'],
|
|
19
|
+
].map(([platform, arch]) => {
|
|
20
|
+
const info = target(platform, arch);
|
|
21
|
+
return `assh_${version}_${info.os}_${info.arch}${info.archiveExt}`;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function verifyArtifactFiles(files, version = pkg.version) {
|
|
26
|
+
for (const archive of expectedArchives(version)) {
|
|
27
|
+
if (!files.includes(archive)) {
|
|
28
|
+
throw new Error(`missing snapshot archive matching installer expectation: ${archive}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!files.includes('checksums.txt')) {
|
|
33
|
+
throw new Error('missing checksums.txt');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function run(command, args, options = {}) {
|
|
38
|
+
const result = spawnSync(command, args, {
|
|
39
|
+
cwd: root,
|
|
40
|
+
stdio: options.stdio || 'pipe',
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (result.error) {
|
|
45
|
+
throw result.error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (result.status !== 0) {
|
|
49
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
50
|
+
throw new Error(`${command} ${args.join(' ')} failed${output ? `:\n${output}` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (result.stdout || '').trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function git(args, options = {}) {
|
|
57
|
+
return run('git', args, options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasGitRef(ref) {
|
|
61
|
+
const result = spawnSync('git', ['rev-parse', '--quiet', '--verify', ref], {
|
|
62
|
+
cwd: root,
|
|
63
|
+
stdio: 'ignore',
|
|
64
|
+
});
|
|
65
|
+
return result.status === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ensureReleaseGitState(version = pkg.version) {
|
|
69
|
+
const tag = `v${version}`;
|
|
70
|
+
let addedRemote = false;
|
|
71
|
+
let addedTag = false;
|
|
72
|
+
const cleanup = () => {
|
|
73
|
+
if (addedTag) {
|
|
74
|
+
git(['tag', '-d', tag]);
|
|
75
|
+
}
|
|
76
|
+
if (addedRemote) {
|
|
77
|
+
git(['remote', 'remove', 'origin']);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const remote = spawnSync('git', ['remote', 'get-url', 'origin'], {
|
|
83
|
+
cwd: root,
|
|
84
|
+
stdio: 'ignore',
|
|
85
|
+
});
|
|
86
|
+
if (remote.status !== 0) {
|
|
87
|
+
git(['remote', 'add', 'origin', releaseRemote]);
|
|
88
|
+
addedRemote = true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!hasGitRef(`refs/tags/${tag}`)) {
|
|
92
|
+
git(['tag', tag, 'HEAD']);
|
|
93
|
+
addedTag = true;
|
|
94
|
+
} else {
|
|
95
|
+
const pointsAtHead = git(['tag', '--points-at', 'HEAD']).split(/\r?\n/).includes(tag);
|
|
96
|
+
if (!pointsAtHead) {
|
|
97
|
+
throw new Error(`tag ${tag} exists but does not point at HEAD`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return cleanup;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
try {
|
|
104
|
+
cleanup();
|
|
105
|
+
} catch (cleanupError) {
|
|
106
|
+
error.message = `${error.message}\ncleanup failed: ${cleanupError.message}`;
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function goreleaserArgs() {
|
|
113
|
+
if (spawnSync('goreleaser', ['--version'], { stdio: 'ignore' }).status === 0) {
|
|
114
|
+
return ['goreleaser', ['release', '--snapshot', '--clean']];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return ['go', [
|
|
118
|
+
'run',
|
|
119
|
+
'github.com/goreleaser/goreleaser/v2@latest',
|
|
120
|
+
'release',
|
|
121
|
+
'--snapshot',
|
|
122
|
+
'--clean',
|
|
123
|
+
]];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function artifactFiles(directory = path.join(root, 'dist')) {
|
|
127
|
+
const files = [];
|
|
128
|
+
|
|
129
|
+
function walk(current) {
|
|
130
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
131
|
+
const full = path.join(current, entry.name);
|
|
132
|
+
if (entry.isDirectory()) {
|
|
133
|
+
walk(full);
|
|
134
|
+
} else {
|
|
135
|
+
files.push(path.basename(full));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
walk(directory);
|
|
141
|
+
return files;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function main() {
|
|
145
|
+
const cleanup = ensureReleaseGitState();
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const [command, args] = goreleaserArgs();
|
|
149
|
+
run(command, args, { stdio: 'inherit' });
|
|
150
|
+
verifyArtifactFiles(artifactFiles());
|
|
151
|
+
console.log('release artifact contract ok');
|
|
152
|
+
} finally {
|
|
153
|
+
cleanup();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (require.main === module) {
|
|
158
|
+
try {
|
|
159
|
+
main();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error(error.message);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
expectedArchives,
|
|
168
|
+
verifyArtifactFiles,
|
|
169
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const { target } = require('./platform');
|
|
8
|
+
const { expectedArchives, verifyArtifactFiles } = require('./release-contract-test');
|
|
9
|
+
|
|
10
|
+
const root = path.join(__dirname, '..');
|
|
11
|
+
const nativeDir = path.join(root, 'native');
|
|
12
|
+
|
|
13
|
+
assert.deepEqual(target('linux', 'x64'), {
|
|
14
|
+
os: 'linux',
|
|
15
|
+
arch: 'amd64',
|
|
16
|
+
ext: '',
|
|
17
|
+
archiveExt: '.tar.gz',
|
|
18
|
+
});
|
|
19
|
+
assert.deepEqual(target('darwin', 'arm64'), {
|
|
20
|
+
os: 'darwin',
|
|
21
|
+
arch: 'arm64',
|
|
22
|
+
ext: '',
|
|
23
|
+
archiveExt: '.tar.gz',
|
|
24
|
+
});
|
|
25
|
+
assert.deepEqual(target('win32', 'x64'), {
|
|
26
|
+
os: 'windows',
|
|
27
|
+
arch: 'amd64',
|
|
28
|
+
ext: '.exe',
|
|
29
|
+
archiveExt: '.zip',
|
|
30
|
+
});
|
|
31
|
+
assert.throws(() => target('win32', 'arm64'), /windows\/arm64/);
|
|
32
|
+
assert.throws(() => target('freebsd', 'x64'), /Unsupported platform/);
|
|
33
|
+
assert.throws(() => target('linux', 'ia32'), /Unsupported architecture/);
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(expectedArchives('1.0.0'), [
|
|
36
|
+
'assh_1.0.0_linux_amd64.tar.gz',
|
|
37
|
+
'assh_1.0.0_linux_arm64.tar.gz',
|
|
38
|
+
'assh_1.0.0_darwin_amd64.tar.gz',
|
|
39
|
+
'assh_1.0.0_darwin_arm64.tar.gz',
|
|
40
|
+
'assh_1.0.0_windows_amd64.zip',
|
|
41
|
+
]);
|
|
42
|
+
verifyArtifactFiles([
|
|
43
|
+
'assh_1.0.0_linux_amd64.tar.gz',
|
|
44
|
+
'assh_1.0.0_linux_arm64.tar.gz',
|
|
45
|
+
'assh_1.0.0_darwin_amd64.tar.gz',
|
|
46
|
+
'assh_1.0.0_darwin_arm64.tar.gz',
|
|
47
|
+
'assh_1.0.0_windows_amd64.zip',
|
|
48
|
+
'checksums.txt',
|
|
49
|
+
], '1.0.0');
|
|
50
|
+
assert.throws(() => verifyArtifactFiles([
|
|
51
|
+
'assh_0.0.0_linux_amd64.tar.gz',
|
|
52
|
+
'checksums.txt',
|
|
53
|
+
], '1.0.0'), /missing snapshot archive/);
|
|
54
|
+
|
|
55
|
+
const info = target();
|
|
56
|
+
const binaryPath = path.join(nativeDir, `assh${info.ext}`);
|
|
57
|
+
const fake = process.platform === 'win32'
|
|
58
|
+
? '#!/usr/bin/env node\r\nconsole.log(`assh smoke ${process.argv.slice(2).join(" ")}`);\r\n'
|
|
59
|
+
: '#!/bin/sh\necho "assh smoke $*"\n';
|
|
60
|
+
|
|
61
|
+
const nativeExisted = fs.existsSync(nativeDir);
|
|
62
|
+
const binaryExisted = fs.existsSync(binaryPath);
|
|
63
|
+
const originalBinary = binaryExisted ? fs.readFileSync(binaryPath) : null;
|
|
64
|
+
const originalMode = binaryExisted ? fs.statSync(binaryPath).mode & 0o777 : null;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
fs.mkdirSync(nativeDir, { recursive: true });
|
|
68
|
+
fs.writeFileSync(binaryPath, fake, { mode: 0o755 });
|
|
69
|
+
fs.chmodSync(binaryPath, 0o755);
|
|
70
|
+
|
|
71
|
+
const result = spawnSync(process.execPath, [path.join(root, 'bin', 'assh.js'), 'arg-one'], {
|
|
72
|
+
cwd: root,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
77
|
+
assert.match(result.stdout, /assh smoke/);
|
|
78
|
+
|
|
79
|
+
console.log('smoke ok');
|
|
80
|
+
} finally {
|
|
81
|
+
if (binaryExisted) {
|
|
82
|
+
fs.writeFileSync(binaryPath, originalBinary, { mode: originalMode });
|
|
83
|
+
fs.chmodSync(binaryPath, originalMode);
|
|
84
|
+
} else {
|
|
85
|
+
fs.rmSync(binaryPath, { force: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!nativeExisted && fs.existsSync(nativeDir) && fs.readdirSync(nativeDir).length === 0) {
|
|
89
|
+
fs.rmdirSync(nativeDir);
|
|
90
|
+
}
|
|
91
|
+
}
|