@seomi/ssh 0.1.2 → 0.2.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/README.md +16 -8
- package/README.ru.md +15 -7
- package/bin/seomi-ssh.mjs +12 -7
- package/package.json +1 -1
- package/src/commands/init.mjs +20 -7
- package/src/commands/update.mjs +122 -0
- package/src/lib/env-writer.mjs +31 -0
- package/src/lib/server-prompt.mjs +125 -3
package/README.md
CHANGED
|
@@ -28,9 +28,12 @@ seomi-ssh init
|
|
|
28
28
|
`ssh` / `scp` / `rsync` examples, values pulled from `.claude/.env`.
|
|
29
29
|
- **`aif-ssh` skill** — copied into the project's `.claude/skills/` so the agent knows how
|
|
30
30
|
to use the configured access.
|
|
31
|
+
- **`update`** — non-interactive. Re-reads every server from `.claude/.env`, regenerates the
|
|
32
|
+
managed block, and heals a drifted `SSH_SERVERS` registry (recovering servers whose keys are
|
|
33
|
+
present but fell out of the registry). No prompts, no key setup.
|
|
31
34
|
- **Idempotent** — re-running never duplicates keys, env entries, or the managed block.
|
|
32
35
|
|
|
33
|
-
> `
|
|
36
|
+
> `doctor` is declared in `--help` but is not implemented yet (stub).
|
|
34
37
|
|
|
35
38
|
## Commands
|
|
36
39
|
|
|
@@ -38,10 +41,11 @@ seomi-ssh init
|
|
|
38
41
|
|---------|--------------|
|
|
39
42
|
| `seomi-ssh init` | Interactive setup (see above) |
|
|
40
43
|
| `seomi-ssh init --dry-run` | Run the prompts without touching disk or SSH; preview the block |
|
|
41
|
-
| `seomi-ssh
|
|
42
|
-
| `seomi-ssh --
|
|
43
|
-
| `seomi-ssh --
|
|
44
|
-
| `seomi-ssh
|
|
44
|
+
| `seomi-ssh update` | Regenerate the managed block from `.claude/.env` and heal `SSH_SERVERS` |
|
|
45
|
+
| `seomi-ssh update --dry-run` | Preview the regenerated block without writing |
|
|
46
|
+
| `seomi-ssh <cmd> --verbose` | Add debug-level logging |
|
|
47
|
+
| `seomi-ssh --help` / `--version` | Show usage / print version |
|
|
48
|
+
| `seomi-ssh doctor` | Reserved (not implemented yet) |
|
|
45
49
|
|
|
46
50
|
## Requirements
|
|
47
51
|
|
|
@@ -72,14 +76,17 @@ One server = one key group:
|
|
|
72
76
|
|-----|---------|---------|
|
|
73
77
|
| `SSH_<PREFIX>_HOST` | domain or IP | always |
|
|
74
78
|
| `SSH_<PREFIX>_USER` | SSH user | always |
|
|
79
|
+
| `SSH_<PREFIX>_ROLE` | human role label (as entered) | always |
|
|
75
80
|
| `SSH_<PREFIX>_PORT` | port | if provided |
|
|
76
81
|
| `SSH_<PREFIX>_KEY` | private key path | always |
|
|
77
82
|
| `SSH_<PREFIX>_ROOT` | remote working directory | if provided |
|
|
78
|
-
| `SSH_SERVERS` | csv registry of all prefixes | always |
|
|
83
|
+
| `SSH_SERVERS` | csv registry (union) of all prefixes | always |
|
|
79
84
|
|
|
80
85
|
`<PREFIX>` is the role normalized to `UPPER_SNAKE_CASE` (`prod` → `PROD`, `staging-eu` →
|
|
81
|
-
`STAGING_EU`); duplicate roles get a unique suffix (`PROD`, then `PROD_2`).
|
|
82
|
-
|
|
86
|
+
`STAGING_EU`); duplicate roles get a unique suffix (`PROD`, then `PROD_2`). `SSH_<PREFIX>_ROLE`
|
|
87
|
+
preserves the original label verbatim (the prefix alone loses dashes), with a `prefix.toLowerCase()`
|
|
88
|
+
fallback for older files. `.claude/.env` is the **first place** the agent looks for access
|
|
89
|
+
details — not `~/.ssh/config`.
|
|
83
90
|
|
|
84
91
|
## Example
|
|
85
92
|
|
|
@@ -103,6 +110,7 @@ $ seomi-ssh init
|
|
|
103
110
|
| Guide | Description |
|
|
104
111
|
|-------|-------------|
|
|
105
112
|
| [`init` command](https://github.com/Mikeekb/seomi-ssh/blob/main/docs/init.md) | SSH setup behavior, `.claude/.env` configuration, examples, troubleshooting |
|
|
113
|
+
| [`update` command](https://github.com/Mikeekb/seomi-ssh/blob/main/docs/update.md) | Regenerate the managed block from `.claude/.env`, heal `SSH_SERVERS` drift |
|
|
106
114
|
|
|
107
115
|
## License
|
|
108
116
|
|
package/README.ru.md
CHANGED
|
@@ -28,9 +28,12 @@ seomi-ssh init
|
|
|
28
28
|
`ssh` / `scp` / `rsync`, значения берутся из `.claude/.env`.
|
|
29
29
|
- **Skill `aif-ssh`** — копируется в `.claude/skills/` проекта и учит агента пользоваться
|
|
30
30
|
настроенным доступом.
|
|
31
|
+
- **`update`** — неинтерактивная. Перечитывает все серверы из `.claude/.env`, перерисовывает
|
|
32
|
+
managed-блок и лечит рассинхрон реестра `SSH_SERVERS` (восстанавливает серверы, чьи ключи
|
|
33
|
+
есть, но выпали из реестра). Без вопросов и без настройки ключей.
|
|
31
34
|
- **Идемпотентность** — повторный запуск не дублирует ключи, env-записи и managed-блок.
|
|
32
35
|
|
|
33
|
-
> `
|
|
36
|
+
> `doctor` объявлена в `--help`, но пока не реализована (заглушка).
|
|
34
37
|
|
|
35
38
|
## Команды
|
|
36
39
|
|
|
@@ -38,10 +41,11 @@ seomi-ssh init
|
|
|
38
41
|
|---------|------------|
|
|
39
42
|
| `seomi-ssh init` | Интерактивная настройка (см. выше) |
|
|
40
43
|
| `seomi-ssh init --dry-run` | Прогон опроса без записи на диск и SSH-вызовов; превью блока |
|
|
41
|
-
| `seomi-ssh
|
|
42
|
-
| `seomi-ssh --
|
|
43
|
-
| `seomi-ssh --
|
|
44
|
-
| `seomi-ssh
|
|
44
|
+
| `seomi-ssh update` | Перерисовать managed-блок из `.claude/.env` и вылечить `SSH_SERVERS` |
|
|
45
|
+
| `seomi-ssh update --dry-run` | Превью перерисованного блока без записи |
|
|
46
|
+
| `seomi-ssh <cmd> --verbose` | Включает debug-логирование |
|
|
47
|
+
| `seomi-ssh --help` / `--version` | Справка / версия |
|
|
48
|
+
| `seomi-ssh doctor` | Зарезервирована (пока не реализована) |
|
|
45
49
|
|
|
46
50
|
## Требования
|
|
47
51
|
|
|
@@ -72,14 +76,17 @@ seomi-ssh init
|
|
|
72
76
|
|------|------------|---------|
|
|
73
77
|
| `SSH_<PREFIX>_HOST` | домен или IP | всегда |
|
|
74
78
|
| `SSH_<PREFIX>_USER` | SSH-пользователь | всегда |
|
|
79
|
+
| `SSH_<PREFIX>_ROLE` | человекочитаемая метка роли (как введена) | всегда |
|
|
75
80
|
| `SSH_<PREFIX>_PORT` | порт | если задан |
|
|
76
81
|
| `SSH_<PREFIX>_KEY` | путь к приватному ключу | всегда |
|
|
77
82
|
| `SSH_<PREFIX>_ROOT` | рабочая директория на сервере | если задана |
|
|
78
|
-
| `SSH_SERVERS` | csv-реестр всех префиксов | всегда |
|
|
83
|
+
| `SSH_SERVERS` | csv-реестр (объединение) всех префиксов | всегда |
|
|
79
84
|
|
|
80
85
|
`<PREFIX>` — это роль, нормализованная в `UPPER_SNAKE_CASE` (`prod` → `PROD`, `staging-eu` →
|
|
81
86
|
`STAGING_EU`); при повторе роли префикс получает уникальный суффикс (`PROD`, затем `PROD_2`).
|
|
82
|
-
|
|
87
|
+
`SSH_<PREFIX>_ROLE` хранит исходную метку дословно (префикс теряет дефисы), с fallback
|
|
88
|
+
`prefix.toLowerCase()` для старых файлов. `.claude/.env` — **первое место**, куда агент
|
|
89
|
+
смотрит за реквизитами доступа, а не `~/.ssh/config`.
|
|
83
90
|
|
|
84
91
|
## Пример
|
|
85
92
|
|
|
@@ -103,6 +110,7 @@ $ seomi-ssh init
|
|
|
103
110
|
| Раздел | Описание |
|
|
104
111
|
|--------|----------|
|
|
105
112
|
| [Команда `init`](https://github.com/Mikeekb/seomi-ssh/blob/main/docs/init.md) | Поведение `init`, конфигурация `.claude/.env`, примеры, диагностика |
|
|
113
|
+
| [Команда `update`](https://github.com/Mikeekb/seomi-ssh/blob/main/docs/update.md) | Перерисовка managed-блока из `.claude/.env`, лечение рассинхрона `SSH_SERVERS` |
|
|
106
114
|
|
|
107
115
|
## Лицензия
|
|
108
116
|
|
package/bin/seomi-ssh.mjs
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* seomi-ssh — entry point.
|
|
4
4
|
*
|
|
5
|
-
* Parses argv, sets the log level, and routes to a subcommand.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Parses argv, sets the log level, and routes to a subcommand. `init` and
|
|
6
|
+
* `update` are implemented; `doctor` is a declared stub so the surface is
|
|
7
|
+
* stable and discoverable.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { parseArgs } from 'node:util';
|
|
11
11
|
import { initCommand } from '../src/commands/init.mjs';
|
|
12
|
+
import { updateCommand } from '../src/commands/update.mjs';
|
|
12
13
|
import { logger } from '../src/lib/logger.mjs';
|
|
13
14
|
|
|
14
15
|
const HELP = `Usage: seomi-ssh <command> [options]
|
|
@@ -19,14 +20,17 @@ Commands:
|
|
|
19
20
|
the connection params to .claude/.env, render the managed
|
|
20
21
|
access block into AGENTS.md / CLAUDE.md, and drop the aif-ssh
|
|
21
22
|
skill into .claude/skills/.
|
|
22
|
-
update
|
|
23
|
-
.claude/.env
|
|
23
|
+
update Non-interactive: re-read every configured server from
|
|
24
|
+
.claude/.env, regenerate the managed access block in the
|
|
25
|
+
existing AGENTS.md / CLAUDE.md, and heal a drifted SSH_SERVERS
|
|
26
|
+
registry (recovering servers whose keys are present but fell
|
|
27
|
+
out of the registry). Supports --dry-run.
|
|
24
28
|
doctor (not implemented yet) Diagnose configured servers (which are
|
|
25
29
|
set up, which are reachable by key).
|
|
26
30
|
|
|
27
31
|
Global options:
|
|
28
32
|
--verbose Enable debug logging (DEBUG level).
|
|
29
|
-
--dry-run init only:
|
|
33
|
+
--dry-run init/update only: make no changes; preview the block.
|
|
30
34
|
--help, -h Show this help.
|
|
31
35
|
--version, -v Print version.
|
|
32
36
|
`;
|
|
@@ -76,9 +80,10 @@ async function main() {
|
|
|
76
80
|
case 'init':
|
|
77
81
|
return await initCommand( opts );
|
|
78
82
|
case 'update':
|
|
83
|
+
return await updateCommand( opts );
|
|
79
84
|
case 'doctor':
|
|
80
85
|
logger.warn( `Команда «${ command }» ещё не реализована в этой версии.` );
|
|
81
|
-
logger.info( '
|
|
86
|
+
logger.info( 'Доступны `seomi-ssh init` и `seomi-ssh update`. См. `seomi-ssh --help`.' );
|
|
82
87
|
return 1;
|
|
83
88
|
default:
|
|
84
89
|
logger.error( `Unknown command: ${ command }` );
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seomi/ssh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "One-command CLI installer that sets up passwordless SSH access for AI agents in any project (dev/prod/custom servers) and writes the access map into AGENTS.md / CLAUDE.md.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/init.mjs
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Thin command: it sequences lib utilities and owns no business logic itself.
|
|
5
5
|
* Flow:
|
|
6
6
|
* 1. promptServers() — interactive loop (1..N servers)
|
|
7
|
-
*
|
|
7
|
+
* + mergeServers() — fold in servers already in .claude/.env so
|
|
8
|
+
* a re-run adds rather than overwrites
|
|
9
|
+
* 2. ensureSshKey() per session server — keygen → copy → verify → manual hint
|
|
8
10
|
* 3. mergeEnv() — write SSH_<PREFIX>_* + SSH_SERVERS to .claude/.env
|
|
9
11
|
* 4. detectAgentMdTargets() — pick AGENTS.md / CLAUDE.md / both
|
|
10
12
|
* 5. renderAgentMdBlock() + insertOrUpdate() — managed block per target
|
|
@@ -19,8 +21,8 @@ import { existsSync } from 'node:fs';
|
|
|
19
21
|
import { join } from 'node:path';
|
|
20
22
|
import { fileURLToPath } from 'node:url';
|
|
21
23
|
import { logger } from '../lib/logger.mjs';
|
|
22
|
-
import { promptServers, toEnvUpdates } from '../lib/server-prompt.mjs';
|
|
23
|
-
import { mergeEnv } from '../lib/env-writer.mjs';
|
|
24
|
+
import { promptServers, toEnvUpdates, serversFromEnv, mergeServers } from '../lib/server-prompt.mjs';
|
|
25
|
+
import { mergeEnv, readEnv } from '../lib/env-writer.mjs';
|
|
24
26
|
import { ensureSshKey } from '../lib/ssh-key-setup.mjs';
|
|
25
27
|
import { detectAgentMdTargets, ensureClaudeImportStub, isClaudeImportStub } from '../lib/agent-md-target.mjs';
|
|
26
28
|
import { renderAgentMdBlock } from '../lib/agent-md-renderer.mjs';
|
|
@@ -69,16 +71,27 @@ export async function initCommand( options = {} ) {
|
|
|
69
71
|
|
|
70
72
|
// --- 1. Prompt for servers -------------------------------------------
|
|
71
73
|
logger.step( 'Шаг 1: Опрос серверов' );
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
+
const session = await promptServers( { _prompts: options._prompts } );
|
|
75
|
+
if ( session.length === 0 ) {
|
|
74
76
|
logger.warn( 'Серверы не заданы — нечего настраивать. Выход.' );
|
|
75
77
|
return 0;
|
|
76
78
|
}
|
|
77
|
-
|
|
79
|
+
|
|
80
|
+
// Merge with servers already configured in .claude/.env so a re-run that
|
|
81
|
+
// adds one server never drops the others from the block or SSH_SERVERS.
|
|
82
|
+
// readEnv is read-only — safe in dry-run too.
|
|
83
|
+
const existing = serversFromEnv( await readEnv( envPath ) );
|
|
84
|
+
const servers = mergeServers( existing, session );
|
|
85
|
+
logger.success(
|
|
86
|
+
`Введено серверов: ${ session.length }` +
|
|
87
|
+
( existing.length ? ` (уже настроено: ${ existing.length }, итого: ${ servers.length })` : '' )
|
|
88
|
+
);
|
|
78
89
|
|
|
79
90
|
// --- 2. SSH key wizard per server ------------------------------------
|
|
91
|
+
// Only the servers entered this session — already-configured ones keep
|
|
92
|
+
// their existing keys and must not trigger another keygen/copy.
|
|
80
93
|
logger.step( 'Шаг 2: Настройка SSH-ключей' );
|
|
81
|
-
for ( const srv of
|
|
94
|
+
for ( const srv of session ) {
|
|
82
95
|
if ( dryRun ) {
|
|
83
96
|
logger.info( `[dry-run] ensureSshKey для «${ srv.role }» (${ srv.user }@${ srv.host }) пропущен` );
|
|
84
97
|
continue;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `seomi-ssh update` — regenerate the managed block from `.claude/.env`.
|
|
3
|
+
*
|
|
4
|
+
* Non-interactive companion to `init`: it re-reads every configured server from
|
|
5
|
+
* `.claude/.env` and re-renders the managed `seomi-ssh` block into the existing
|
|
6
|
+
* AGENTS.md / CLAUDE.md. As a healing side effect it rewrites `SSH_<PREFIX>_*`
|
|
7
|
+
* and the `SSH_SERVERS` registry from the full discovered set, so a `.env` that
|
|
8
|
+
* drifted (orphan prefixes missing from `SSH_SERVERS`, or pre-`ROLE` files) is
|
|
9
|
+
* brought back in sync on the first run.
|
|
10
|
+
*
|
|
11
|
+
* Thin command: it sequences lib utilities and owns no business logic itself.
|
|
12
|
+
* Flow:
|
|
13
|
+
* 1. serversFromEnv(readEnv()) — enumerate every configured server
|
|
14
|
+
* 2. mergeEnv() — heal SSH_SERVERS + write SSH_<PREFIX>_ROLE
|
|
15
|
+
* 3. renderAgentMdBlock() + insertOrUpdate() — refresh the block in place
|
|
16
|
+
*
|
|
17
|
+
* Unlike `init`, `update` never creates an instructions file: when neither
|
|
18
|
+
* AGENTS.md nor CLAUDE.md exists it warns and points the user at `init`.
|
|
19
|
+
*
|
|
20
|
+
* Out of scope (by design): npm self-update of the package (the `+ self-update`
|
|
21
|
+
* note in AGENTS.md) and any interactive prompting.
|
|
22
|
+
*
|
|
23
|
+
* `--dry-run` performs NO writes; it prints what would change and the block.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { logger } from '../lib/logger.mjs';
|
|
29
|
+
import { serversFromEnv, toEnvUpdates } from '../lib/server-prompt.mjs';
|
|
30
|
+
import { readEnv, mergeEnv } from '../lib/env-writer.mjs';
|
|
31
|
+
import { detectAgentMdTargets, ensureClaudeImportStub, isClaudeImportStub } from '../lib/agent-md-target.mjs';
|
|
32
|
+
import { renderAgentMdBlock } from '../lib/agent-md-renderer.mjs';
|
|
33
|
+
import { insertOrUpdate } from '../lib/markers.mjs';
|
|
34
|
+
|
|
35
|
+
const MARKER_NS = 'seomi-ssh';
|
|
36
|
+
|
|
37
|
+
function packagePath( ...segments ) {
|
|
38
|
+
// update.mjs lives at <pkg>/src/commands/ — climb two levels to the package root.
|
|
39
|
+
return fileURLToPath( new URL( join( '../../', ...segments ), import.meta.url ) );
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function printSummary( servers, dryRun ) {
|
|
43
|
+
logger.info( `Серверов в реестре: ${ servers.length }${ dryRun ? ' (dry-run, без записи)' : '' }` );
|
|
44
|
+
for ( const s of servers ) {
|
|
45
|
+
logger.info( ` • ${ s.role } → ${ s.user }@${ s.host }${ s.port && s.port !== '22' ? `:${ s.port }` : '' } (env SSH_${ s.prefix }_*)` );
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} [options]
|
|
51
|
+
* @param {string} [options.cwd] — project root (default process.cwd()).
|
|
52
|
+
* @param {boolean} [options['dry-run']] — no side effects, just preview.
|
|
53
|
+
* @param {string} [options.envPath] — override .claude/.env location.
|
|
54
|
+
* @param {string} [options.templatePath] — override bundled template path.
|
|
55
|
+
* @returns {Promise<number>} process exit code (1 when nothing is configured).
|
|
56
|
+
*/
|
|
57
|
+
export async function updateCommand( options = {} ) {
|
|
58
|
+
const cwd = options.cwd || process.cwd();
|
|
59
|
+
const dryRun = Boolean( options[ 'dry-run' ] || options.dryRun );
|
|
60
|
+
const envPath = options.envPath || join( cwd, '.claude', '.env' );
|
|
61
|
+
const templatePath = options.templatePath || packagePath( 'templates', 'agent-md-block.md' );
|
|
62
|
+
|
|
63
|
+
logger.step( 'seomi-ssh update' + ( dryRun ? ' (dry-run)' : '' ) );
|
|
64
|
+
|
|
65
|
+
// --- 1. Re-read every configured server from .claude/.env ------------
|
|
66
|
+
logger.step( 'Шаг 1: Чтение серверов из .claude/.env' );
|
|
67
|
+
const servers = serversFromEnv( await readEnv( envPath ) );
|
|
68
|
+
if ( servers.length === 0 ) {
|
|
69
|
+
logger.warn( `Серверы в ${ envPath } не найдены — нечего обновлять.` );
|
|
70
|
+
logger.info( 'Запусти `seomi-ssh init`, чтобы настроить доступ.' );
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
logger.success( `Найдено серверов: ${ servers.length }` );
|
|
74
|
+
|
|
75
|
+
// --- 2. Heal the .claude/.env registry (SSH_SERVERS + SSH_<P>_ROLE) --
|
|
76
|
+
logger.step( 'Шаг 2: Синхронизация реестра .claude/.env' );
|
|
77
|
+
const updates = toEnvUpdates( servers );
|
|
78
|
+
if ( dryRun ) {
|
|
79
|
+
logger.info( `[dry-run] mergeEnv пропущен; были бы синхронизированы ключи: ${ Object.keys( updates ).join( ', ' ) }` );
|
|
80
|
+
} else {
|
|
81
|
+
const r = await mergeEnv( envPath, updates );
|
|
82
|
+
logger.success( `.claude/.env: добавлено ${ r.added.length }, обновлено ${ r.updated.length }, без изменений ${ r.unchanged.length }` );
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- 3. Re-render the managed block into existing targets only -------
|
|
86
|
+
logger.step( 'Шаг 3: Перерисовка managed-блока' );
|
|
87
|
+
const block = await renderAgentMdBlock( servers, templatePath );
|
|
88
|
+
const { targets, source } = await detectAgentMdTargets( { cwd, interactive: false } );
|
|
89
|
+
|
|
90
|
+
if ( source === 'default' ) {
|
|
91
|
+
// Neither AGENTS.md nor CLAUDE.md exists. `update` refreshes existing
|
|
92
|
+
// instructions; it must not create the file from scratch — that's `init`.
|
|
93
|
+
logger.warn( 'Нет ни AGENTS.md, ни CLAUDE.md — update не создаёт файлы инструкций.' );
|
|
94
|
+
logger.info( 'Запусти `seomi-ssh init`, чтобы создать файл инструкций агенту.' );
|
|
95
|
+
} else if ( dryRun ) {
|
|
96
|
+
logger.info( `[dry-run] managed-блок был бы обновлён в: ${ targets.join( ', ' ) }` );
|
|
97
|
+
process.stdout.write( '\n--- managed block preview ---\n' + block + '--- end preview ---\n' );
|
|
98
|
+
} else {
|
|
99
|
+
for ( const file of targets ) {
|
|
100
|
+
const name = file.split( /[\\/]/ ).pop();
|
|
101
|
+
// A CLAUDE.md that merely imports AGENTS.md is a redirect, not a
|
|
102
|
+
// block host — writing the block into it would duplicate content.
|
|
103
|
+
if ( name === 'CLAUDE.md' && isClaudeImportStub( file ) ) {
|
|
104
|
+
logger.info( `${ name }: оставлен как импорт @AGENTS.md (блок живёт в AGENTS.md)` );
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const res = await insertOrUpdate( file, block, { namespace: MARKER_NS } );
|
|
108
|
+
logger.success( `${ res.action }: ${ file }` );
|
|
109
|
+
}
|
|
110
|
+
// If the block lives in AGENTS.md and Claude Code has no CLAUDE.md to
|
|
111
|
+
// read, drop the one-line @AGENTS.md import stub (idempotent no-op
|
|
112
|
+
// otherwise) — same guarantee `init` provides.
|
|
113
|
+
const stub = await ensureClaudeImportStub( { cwd } );
|
|
114
|
+
if ( stub.created ) {
|
|
115
|
+
logger.success( 'Создан CLAUDE.md (импорт @AGENTS.md) — Claude Code теперь читает AGENTS.md' );
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
logger.step( 'Готово' );
|
|
120
|
+
printSummary( servers, dryRun );
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
package/src/lib/env-writer.mjs
CHANGED
|
@@ -35,6 +35,37 @@ export function serializeEnv( items ) {
|
|
|
35
35
|
} ).join( '\n' );
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Read the file at `filePath` into a flat `{ key: value }` map.
|
|
40
|
+
*
|
|
41
|
+
* Comments/blanks are ignored and, on duplicate keys, the FIRST occurrence
|
|
42
|
+
* wins (mirrors `mergeEnv`, which only updates the first occurrence). Returns
|
|
43
|
+
* an empty object when the file does not exist — callers treat "no file" and
|
|
44
|
+
* "no servers" identically, so this keeps them branch-free.
|
|
45
|
+
*
|
|
46
|
+
* This is the read half of the env round-trip: `serversFromEnv` turns the map
|
|
47
|
+
* back into server objects so `init`/`update` can re-render from disk.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} filePath
|
|
50
|
+
* @returns {Promise<Record<string,string>>}
|
|
51
|
+
*/
|
|
52
|
+
export async function readEnv( filePath ) {
|
|
53
|
+
if ( ! existsSync( filePath ) ) {
|
|
54
|
+
logger.debug( `env-writer: readEnv — ${ filePath } does not exist, returning {}` );
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
const text = await readFile( filePath, 'utf8' );
|
|
58
|
+
const items = parseEnv( text );
|
|
59
|
+
const map = {};
|
|
60
|
+
for ( const it of items ) {
|
|
61
|
+
if ( it.type !== 'kv' ) continue;
|
|
62
|
+
if ( it.key in map ) continue; // first occurrence wins
|
|
63
|
+
map[ it.key ] = it.value;
|
|
64
|
+
}
|
|
65
|
+
logger.debug( `env-writer: readEnv — ${ Object.keys( map ).length } keys from ${ filePath }` );
|
|
66
|
+
return map;
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
/**
|
|
39
70
|
* Merge `updates` (object) into the file at `filePath`.
|
|
40
71
|
* Returns { created: boolean, added: string[], updated: string[], unchanged: string[] }.
|
|
@@ -30,6 +30,18 @@ const ROLE_PROD = 'prod';
|
|
|
30
30
|
const ROLE_DEV = 'dev';
|
|
31
31
|
const ROLE_CUSTOM = '__custom__';
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Matches a prefixed server key and captures its `<PREFIX>` segment. The `SSH_`
|
|
35
|
+
* anchor keeps it from ever catching unrelated keys (`WP_PROD_SSH_HOST`,
|
|
36
|
+
* `OTHER_KEY`); `SSH_SERVERS` (the registry) has no suffix group and never
|
|
37
|
+
* matches, so it is never mistaken for a server.
|
|
38
|
+
*/
|
|
39
|
+
const SERVER_KEY_RE = /^SSH_(.+)_(HOST|USER|PORT|KEY|ROOT|ROLE)$/;
|
|
40
|
+
|
|
41
|
+
function present( v ) {
|
|
42
|
+
return typeof v === 'string' && v.trim().length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
/**
|
|
34
46
|
* Normalize an arbitrary role label into a bare UPPER_SNAKE_CASE token.
|
|
35
47
|
* Non-alphanumeric runs collapse to a single `_`; leading/trailing `_` are
|
|
@@ -157,9 +169,14 @@ export async function promptServers( { _prompts } = {} ) {
|
|
|
157
169
|
|
|
158
170
|
/**
|
|
159
171
|
* Build the `.claude/.env` updates object for a list of servers.
|
|
160
|
-
* Writes `SSH_<PREFIX>_HOST/USER/PORT/KEY/ROOT` (optional keys
|
|
161
|
-
* blank) plus the `SSH_SERVERS` registry (csv of prefixes) so
|
|
162
|
-
* later enumerate every configured server.
|
|
172
|
+
* Writes `SSH_<PREFIX>_HOST/USER/ROLE/PORT/KEY/ROOT` (optional PORT/ROOT keys
|
|
173
|
+
* skipped when blank) plus the `SSH_SERVERS` registry (csv of prefixes) so
|
|
174
|
+
* `update` can later enumerate every configured server.
|
|
175
|
+
*
|
|
176
|
+
* `SSH_<PREFIX>_ROLE` persists the human role label so it round-trips losslessly
|
|
177
|
+
* through `serversFromEnv` (the prefix alone loses dashes — `staging-eu` →
|
|
178
|
+
* `STAGING_EU`). Older `.env` files without it fall back to the lowercased
|
|
179
|
+
* prefix and self-heal on the next write.
|
|
163
180
|
*/
|
|
164
181
|
export function toEnvUpdates( servers ) {
|
|
165
182
|
const updates = {};
|
|
@@ -167,6 +184,7 @@ export function toEnvUpdates( servers ) {
|
|
|
167
184
|
const p = s.prefix;
|
|
168
185
|
updates[ `SSH_${ p }_HOST` ] = s.host;
|
|
169
186
|
updates[ `SSH_${ p }_USER` ] = s.user;
|
|
187
|
+
if ( s.role ) updates[ `SSH_${ p }_ROLE` ] = s.role;
|
|
170
188
|
if ( s.port ) updates[ `SSH_${ p }_PORT` ] = s.port;
|
|
171
189
|
updates[ `SSH_${ p }_KEY` ] = s.keyPath;
|
|
172
190
|
if ( s.root ) updates[ `SSH_${ p }_ROOT` ] = s.root;
|
|
@@ -174,3 +192,107 @@ export function toEnvUpdates( servers ) {
|
|
|
174
192
|
updates.SSH_SERVERS = servers.map( ( s ) => s.prefix ).join( ',' );
|
|
175
193
|
return updates;
|
|
176
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Reconstruct server objects from a flat `.claude/.env` map (the inverse of
|
|
198
|
+
* `toEnvUpdates`). The set of prefixes is the UNION of the `SSH_SERVERS`
|
|
199
|
+
* registry and every prefix discovered by scanning keys with `SERVER_KEY_RE` —
|
|
200
|
+
* so a server whose keys exist but dropped out of `SSH_SERVERS` (the
|
|
201
|
+
* `os-ndtnew` drift bug) is still recovered. Registry prefixes come first to
|
|
202
|
+
* keep ordering stable; discovered orphans follow.
|
|
203
|
+
*
|
|
204
|
+
* A prefix with no `HOST` is skipped (incomplete, unaddressable). Role falls
|
|
205
|
+
* back to the lowercased prefix when `SSH_<PREFIX>_ROLE` is absent. Pure: does
|
|
206
|
+
* not read disk or mutate its input.
|
|
207
|
+
*
|
|
208
|
+
* @param {Record<string,string>} envMap — map from `readEnv`.
|
|
209
|
+
* @returns {Array<object>} server objects (role/prefix/host/user/port/keyPath/root).
|
|
210
|
+
*/
|
|
211
|
+
export function serversFromEnv( envMap ) {
|
|
212
|
+
const map = envMap || {};
|
|
213
|
+
|
|
214
|
+
const fromRegistry = String( map.SSH_SERVERS || '' )
|
|
215
|
+
.split( ',' )
|
|
216
|
+
.map( ( s ) => s.trim() )
|
|
217
|
+
.filter( Boolean );
|
|
218
|
+
|
|
219
|
+
const discovered = [];
|
|
220
|
+
for ( const key of Object.keys( map ) ) {
|
|
221
|
+
const m = key.match( SERVER_KEY_RE );
|
|
222
|
+
if ( m ) discovered.push( m[ 1 ] );
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Registry-first union, de-duplicated, order-preserving.
|
|
226
|
+
const prefixes = [];
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
for ( const p of [ ...fromRegistry, ...discovered ] ) {
|
|
229
|
+
if ( seen.has( p ) ) continue;
|
|
230
|
+
seen.add( p );
|
|
231
|
+
prefixes.push( p );
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const servers = [];
|
|
235
|
+
const skipped = [];
|
|
236
|
+
for ( const prefix of prefixes ) {
|
|
237
|
+
const host = map[ `SSH_${ prefix }_HOST` ];
|
|
238
|
+
if ( ! present( host ) ) {
|
|
239
|
+
skipped.push( prefix );
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const roleKey = map[ `SSH_${ prefix }_ROLE` ];
|
|
243
|
+
servers.push( {
|
|
244
|
+
role: present( roleKey ) ? roleKey.trim() : prefix.toLowerCase(),
|
|
245
|
+
prefix,
|
|
246
|
+
host: host.trim(),
|
|
247
|
+
user: ( map[ `SSH_${ prefix }_USER` ] || '' ).trim(),
|
|
248
|
+
port: ( map[ `SSH_${ prefix }_PORT` ] || '' ).trim(),
|
|
249
|
+
keyPath: ( map[ `SSH_${ prefix }_KEY` ] || '' ).trim(),
|
|
250
|
+
root: ( map[ `SSH_${ prefix }_ROOT` ] || '' ).trim(),
|
|
251
|
+
} );
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const recovered = servers
|
|
255
|
+
.map( ( s ) => s.prefix )
|
|
256
|
+
.filter( ( p ) => ! fromRegistry.includes( p ) );
|
|
257
|
+
logger.debug(
|
|
258
|
+
`[server-prompt] serversFromEnv: распознано ${ servers.length }, ` +
|
|
259
|
+
`восстановлено orphan-префиксов ${ recovered.length }` +
|
|
260
|
+
( recovered.length ? ` [${ recovered.join( ',' ) }]` : '' ) +
|
|
261
|
+
`, пропущено без HOST ${ skipped.length }` +
|
|
262
|
+
( skipped.length ? ` [${ skipped.join( ',' ) }]` : '' )
|
|
263
|
+
);
|
|
264
|
+
return servers;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Merge two server lists by `prefix`. `incoming` wins on a prefix collision
|
|
269
|
+
* (a re-run of `init` re-entered that server), existing servers keep their
|
|
270
|
+
* original order and come first, and genuinely new incoming servers are
|
|
271
|
+
* appended. Pure: returns fresh objects, mutates neither argument.
|
|
272
|
+
*
|
|
273
|
+
* @param {Array<object>} existing — servers already configured (from `.env`).
|
|
274
|
+
* @param {Array<object>} incoming — servers from this session.
|
|
275
|
+
* @returns {Array<object>} merged list.
|
|
276
|
+
*/
|
|
277
|
+
export function mergeServers( existing, incoming ) {
|
|
278
|
+
const ex = Array.isArray( existing ) ? existing : [];
|
|
279
|
+
const inc = Array.isArray( incoming ) ? incoming : [];
|
|
280
|
+
const incByPrefix = new Map( inc.map( ( s ) => [ s.prefix, s ] ) );
|
|
281
|
+
|
|
282
|
+
const merged = [];
|
|
283
|
+
const used = new Set();
|
|
284
|
+
for ( const s of ex ) {
|
|
285
|
+
merged.push( { ...( incByPrefix.get( s.prefix ) || s ) } );
|
|
286
|
+
used.add( s.prefix );
|
|
287
|
+
}
|
|
288
|
+
for ( const s of inc ) {
|
|
289
|
+
if ( used.has( s.prefix ) ) continue;
|
|
290
|
+
merged.push( { ...s } );
|
|
291
|
+
used.add( s.prefix );
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
logger.debug(
|
|
295
|
+
`[server-prompt] mergeServers: existing=${ ex.length }, incoming=${ inc.length }, итого=${ merged.length }`
|
|
296
|
+
);
|
|
297
|
+
return merged;
|
|
298
|
+
}
|