@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 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
- > `update` and `doctor` are declared in `--help` but are not implemented yet (stubs).
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 init --verbose` | Add debug-level logging |
42
- | `seomi-ssh --help` | Show usage |
43
- | `seomi-ssh --version` | Print version |
44
- | `seomi-ssh update` / `doctor` | Reserved (not implemented yet) |
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`). `.claude/.env`
82
- is the **first place** the agent looks for access details not `~/.ssh/config`.
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
- > `update` и `doctor` объявлены в `--help`, но пока не реализованы (заглушки).
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 init --verbose` | Включает debug-логирование |
42
- | `seomi-ssh --help` | Справка |
43
- | `seomi-ssh --version` | Версия |
44
- | `seomi-ssh update` / `doctor` | Зарезервированы (пока не реализованы) |
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
- `.claude/.env` **первое место**, куда агент смотрит за реквизитами доступа, а не `~/.ssh/config`.
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. Only `init` is
6
- * implemented in this release; `update` and `doctor` are declared stubs so the
7
- * surface is stable and discoverable.
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 (not implemented yet) Regenerate the managed block from
23
- .claude/.env and self-update the package.
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: run prompts but make no changes; preview the block.
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( 'Доступна только `seomi-ssh init`. См. `seomi-ssh --help`.' );
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.1.2",
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": {
@@ -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
- * 2. ensureSshKey() per server keygen copy verify manual hint
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 servers = await promptServers( { _prompts: options._prompts } );
73
- if ( servers.length === 0 ) {
74
+ const session = await promptServers( { _prompts: options._prompts } );
75
+ if ( session.length === 0 ) {
74
76
  logger.warn( 'Серверы не заданы — нечего настраивать. Выход.' );
75
77
  return 0;
76
78
  }
77
- logger.success( `Введено серверов: ${ servers.length }` );
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 servers ) {
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
+ }
@@ -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 skipped when
161
- * blank) plus the `SSH_SERVERS` registry (csv of prefixes) so `update` can
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
+ }