@seomi/ssh 0.1.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 +61 -0
- package/bin/seomi-ssh.mjs +95 -0
- package/package.json +42 -0
- package/skills/aif-ssh/SKILL.md +70 -0
- package/src/commands/init.mjs +147 -0
- package/src/lib/agent-md-renderer.mjs +99 -0
- package/src/lib/agent-md-target.mjs +107 -0
- package/src/lib/env-writer.mjs +90 -0
- package/src/lib/logger.mjs +53 -0
- package/src/lib/markers.mjs +132 -0
- package/src/lib/server-prompt.mjs +176 -0
- package/src/lib/ssh-key-setup.mjs +236 -0
- package/templates/agent-md-block.md +19 -0
- package/templates/claude-dotenv/.env.example +22 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .claude/.env writer — preserves unrelated keys and comments.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: parse the existing file line-by-line into a list of items
|
|
5
|
+
* (each item is either { type: 'comment' | 'blank', text } or
|
|
6
|
+
* { type: 'kv', key, value, raw }), apply key updates in place, and
|
|
7
|
+
* append new keys at the end. Comments and ordering are preserved.
|
|
8
|
+
*
|
|
9
|
+
* This is what makes N-server setups idempotent: writing `SSH_DEV_*` keys
|
|
10
|
+
* must never clobber an already-configured `SSH_PROD_*` block.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
import { logger } from './logger.mjs';
|
|
17
|
+
|
|
18
|
+
const KV_RE = /^([A-Z_][A-Z0-9_]*)=(.*)$/;
|
|
19
|
+
|
|
20
|
+
export function parseEnv( text ) {
|
|
21
|
+
return text.split( /\r?\n/ ).map( ( line ) => {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
if ( trimmed === '' ) return { type: 'blank', text: line };
|
|
24
|
+
if ( trimmed.startsWith( '#' ) ) return { type: 'comment', text: line };
|
|
25
|
+
const m = trimmed.match( KV_RE );
|
|
26
|
+
if ( m ) return { type: 'kv', key: m[1], value: m[2], raw: line };
|
|
27
|
+
return { type: 'other', text: line };
|
|
28
|
+
} );
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function serializeEnv( items ) {
|
|
32
|
+
return items.map( ( it ) => {
|
|
33
|
+
if ( it.type === 'kv' ) return `${ it.key }=${ it.value }`;
|
|
34
|
+
return it.text;
|
|
35
|
+
} ).join( '\n' );
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Merge `updates` (object) into the file at `filePath`.
|
|
40
|
+
* Returns { created: boolean, added: string[], updated: string[], unchanged: string[] }.
|
|
41
|
+
*/
|
|
42
|
+
export async function mergeEnv( filePath, updates ) {
|
|
43
|
+
const added = [];
|
|
44
|
+
const updated = [];
|
|
45
|
+
const unchanged = [];
|
|
46
|
+
|
|
47
|
+
let items;
|
|
48
|
+
let created = false;
|
|
49
|
+
|
|
50
|
+
if ( ! existsSync( filePath ) ) {
|
|
51
|
+
created = true;
|
|
52
|
+
items = [];
|
|
53
|
+
logger.debug( `env-writer: ${ filePath } does not exist, creating` );
|
|
54
|
+
} else {
|
|
55
|
+
const text = await readFile( filePath, 'utf8' );
|
|
56
|
+
items = parseEnv( text );
|
|
57
|
+
logger.debug( `env-writer: parsed ${ items.length } lines from ${ filePath }` );
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for ( const it of items ) {
|
|
62
|
+
if ( it.type !== 'kv' ) continue;
|
|
63
|
+
if ( ! ( it.key in updates ) ) continue;
|
|
64
|
+
if ( seen.has( it.key ) ) continue; // only update first occurrence
|
|
65
|
+
seen.add( it.key );
|
|
66
|
+
const newValue = updates[ it.key ];
|
|
67
|
+
if ( it.value === newValue ) {
|
|
68
|
+
unchanged.push( it.key );
|
|
69
|
+
} else {
|
|
70
|
+
it.value = newValue;
|
|
71
|
+
updated.push( it.key );
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for ( const [ key, value ] of Object.entries( updates ) ) {
|
|
76
|
+
if ( seen.has( key ) ) continue;
|
|
77
|
+
if ( items.length > 0 && items.at( -1 )?.type !== 'blank' ) {
|
|
78
|
+
items.push( { type: 'blank', text: '' } );
|
|
79
|
+
}
|
|
80
|
+
items.push( { type: 'kv', key, value, raw: `${ key }=${ value }` } );
|
|
81
|
+
added.push( key );
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const out = serializeEnv( items );
|
|
85
|
+
await mkdir( dirname( filePath ), { recursive: true } );
|
|
86
|
+
await writeFile( filePath, out.endsWith( '\n' ) ? out : out + '\n', 'utf8' );
|
|
87
|
+
|
|
88
|
+
logger.debug( `env-writer: created=${ created } added=${ added.length } updated=${ updated.length } unchanged=${ unchanged.length }` );
|
|
89
|
+
return { created, added, updated, unchanged };
|
|
90
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight logger with configurable level. No external deps.
|
|
3
|
+
*
|
|
4
|
+
* Levels: debug < info < warn < error. Default: info.
|
|
5
|
+
* Toggle to debug with `--verbose` flag in the CLI.
|
|
6
|
+
*
|
|
7
|
+
* Singleton: imported by every layer. Secrets (private keys, passwords) must
|
|
8
|
+
* NEVER be passed to it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
12
|
+
|
|
13
|
+
const ANSI = {
|
|
14
|
+
reset: '\x1b[0m',
|
|
15
|
+
gray: '\x1b[90m',
|
|
16
|
+
cyan: '\x1b[36m',
|
|
17
|
+
yellow: '\x1b[33m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
bold: '\x1b[1m',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const useColor = process.stdout.isTTY && ! process.env.NO_COLOR;
|
|
24
|
+
const c = ( code, s ) => ( useColor ? `${ code }${ s }${ ANSI.reset }` : s );
|
|
25
|
+
|
|
26
|
+
class Logger {
|
|
27
|
+
#level = LEVELS.info;
|
|
28
|
+
|
|
29
|
+
setLevel( name ) {
|
|
30
|
+
if ( LEVELS[ name ] !== undefined ) {
|
|
31
|
+
this.#level = LEVELS[ name ];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#log( levelName, prefix, color, ...args ) {
|
|
36
|
+
if ( LEVELS[ levelName ] < this.#level ) return;
|
|
37
|
+
const head = c( color, `[${ prefix }]` );
|
|
38
|
+
const time = c( ANSI.gray, new Date().toISOString().slice( 11, 19 ) );
|
|
39
|
+
process.stdout.write( `${ time } ${ head } ${ args.join( ' ' ) }\n` );
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
debug( ...args ) { this.#log( 'debug', 'debug', ANSI.gray, ...args ); }
|
|
43
|
+
info( ...args ) { this.#log( 'info', 'info', ANSI.cyan, ...args ); }
|
|
44
|
+
warn( ...args ) { this.#log( 'warn', 'warn', ANSI.yellow, ...args ); }
|
|
45
|
+
error( ...args ) { this.#log( 'error', 'error', ANSI.red, ...args ); }
|
|
46
|
+
success( ...args ) { this.#log( 'info', 'ok', ANSI.green, ...args ); }
|
|
47
|
+
|
|
48
|
+
step( msg ) {
|
|
49
|
+
process.stdout.write( '\n' + c( ANSI.bold, '› ' + msg ) + '\n' );
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const logger = new Logger();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent marker-block management for files like AGENTS.md / CLAUDE.md.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a managed block between:
|
|
5
|
+
* <!-- seomi-ssh:start --> ... <!-- seomi-ssh:end -->
|
|
6
|
+
*
|
|
7
|
+
* - insertOrUpdate(filePath, content, opts) — creates or replaces the block.
|
|
8
|
+
* - removeBlock(filePath, opts) — strips the block entirely.
|
|
9
|
+
* - hasBlock(filePath, opts) — boolean check.
|
|
10
|
+
*
|
|
11
|
+
* Safe to call repeatedly. Preserves all surrounding content verbatim.
|
|
12
|
+
*
|
|
13
|
+
* Matching is GREEDY (first `:start` to LAST `:end`): older non-greedy
|
|
14
|
+
* versions of this approach shipped templates whose body itself mentioned the
|
|
15
|
+
* literal markers, which caused the non-greedy regex to match only up to the
|
|
16
|
+
* first inline `:end` mention and leave orphan tails accumulating on each
|
|
17
|
+
* `init` run. Greedy matching collapses any such corruption into a single
|
|
18
|
+
* fresh block, and `stripOrphanMarkers` cleans up stray marker lines left
|
|
19
|
+
* outside the matched span.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
23
|
+
import { existsSync } from 'node:fs';
|
|
24
|
+
import { dirname } from 'node:path';
|
|
25
|
+
import { logger } from './logger.mjs';
|
|
26
|
+
|
|
27
|
+
const DEFAULT_NAMESPACE = 'seomi-ssh';
|
|
28
|
+
|
|
29
|
+
function escapeRe( s ) {
|
|
30
|
+
return s.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildMarkers( namespace ) {
|
|
34
|
+
return {
|
|
35
|
+
start: `<!-- ${ namespace }:start -->`,
|
|
36
|
+
end: `<!-- ${ namespace }:end -->`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* GREEDY: from the first `:start` to the LAST `:end`. No `g` flag — we want
|
|
42
|
+
* a single match covering all corrupted/duplicated content as one span.
|
|
43
|
+
*/
|
|
44
|
+
function buildGreedyRegex( namespace ) {
|
|
45
|
+
const ns = escapeRe( namespace );
|
|
46
|
+
return new RegExp(
|
|
47
|
+
`<!--\\s*${ ns }:start\\s*-->[\\s\\S]*<!--\\s*${ ns }:end\\s*-->\\n?`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Matches a standalone marker line (whitespace-only line containing just the
|
|
53
|
+
* start or end marker). Used to clean orphan markers left outside the main
|
|
54
|
+
* managed span by older broken installs.
|
|
55
|
+
*/
|
|
56
|
+
function buildOrphanLineRegex( namespace ) {
|
|
57
|
+
const ns = escapeRe( namespace );
|
|
58
|
+
return new RegExp(
|
|
59
|
+
`^[ \\t]*<!--\\s*${ ns }:(?:start|end)\\s*-->[ \\t]*\\r?\\n?`,
|
|
60
|
+
'gm'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Remove standalone marker lines from `text` EXCEPT inside `block` (the new
|
|
66
|
+
* managed block we are about to write). When `block` is empty, strip
|
|
67
|
+
* everywhere.
|
|
68
|
+
*/
|
|
69
|
+
function stripOrphanMarkers( text, namespace, block ) {
|
|
70
|
+
const lineRegex = buildOrphanLineRegex( namespace );
|
|
71
|
+
if ( ! block ) return text.replace( lineRegex, '' );
|
|
72
|
+
const parts = text.split( block );
|
|
73
|
+
if ( parts.length === 1 ) return text.replace( lineRegex, '' );
|
|
74
|
+
return parts.map( ( p ) => p.replace( lineRegex, '' ) ).join( block );
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function hasBlock( filePath, { namespace = DEFAULT_NAMESPACE } = {} ) {
|
|
78
|
+
if ( ! existsSync( filePath ) ) return false;
|
|
79
|
+
const text = await readFile( filePath, 'utf8' );
|
|
80
|
+
return buildGreedyRegex( namespace ).test( text );
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function insertOrUpdate( filePath, content, { namespace = DEFAULT_NAMESPACE, appendIfNew = true } = {} ) {
|
|
84
|
+
const { start, end } = buildMarkers( namespace );
|
|
85
|
+
const block = `${ start }\n${ content.trim() }\n${ end }\n`;
|
|
86
|
+
|
|
87
|
+
if ( ! existsSync( filePath ) ) {
|
|
88
|
+
await mkdir( dirname( filePath ), { recursive: true } );
|
|
89
|
+
await writeFile( filePath, block, 'utf8' );
|
|
90
|
+
return { action: 'created', filePath };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const text = await readFile( filePath, 'utf8' );
|
|
94
|
+
const greedyRegex = buildGreedyRegex( namespace );
|
|
95
|
+
|
|
96
|
+
if ( greedyRegex.test( text ) ) {
|
|
97
|
+
let updated = text.replace( greedyRegex, block );
|
|
98
|
+
updated = stripOrphanMarkers( updated, namespace, block );
|
|
99
|
+
if ( updated === text ) {
|
|
100
|
+
return { action: 'unchanged', filePath };
|
|
101
|
+
}
|
|
102
|
+
await writeFile( filePath, updated, 'utf8' );
|
|
103
|
+
logger.debug( `markers: replaced managed block in ${ filePath }` );
|
|
104
|
+
return { action: 'updated', filePath };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// No managed span — but stray marker lines from past corruption may still
|
|
108
|
+
// linger. Drop them before deciding whether to append.
|
|
109
|
+
const cleaned = stripOrphanMarkers( text, namespace, '' );
|
|
110
|
+
|
|
111
|
+
if ( ! appendIfNew ) {
|
|
112
|
+
if ( cleaned !== text ) {
|
|
113
|
+
await writeFile( filePath, cleaned, 'utf8' );
|
|
114
|
+
return { action: 'cleaned', filePath };
|
|
115
|
+
}
|
|
116
|
+
return { action: 'not-found', filePath };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sep = cleaned.length > 0 && ! cleaned.endsWith( '\n' ) ? '\n\n' : '\n';
|
|
120
|
+
await writeFile( filePath, cleaned + sep + block, 'utf8' );
|
|
121
|
+
return { action: 'appended', filePath };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function removeBlock( filePath, { namespace = DEFAULT_NAMESPACE } = {} ) {
|
|
125
|
+
if ( ! existsSync( filePath ) ) return { action: 'not-found', filePath };
|
|
126
|
+
const text = await readFile( filePath, 'utf8' );
|
|
127
|
+
let updated = text.replace( buildGreedyRegex( namespace ), '' );
|
|
128
|
+
updated = stripOrphanMarkers( updated, namespace, '' );
|
|
129
|
+
if ( updated === text ) return { action: 'unchanged', filePath };
|
|
130
|
+
await writeFile( filePath, updated, 'utf8' );
|
|
131
|
+
return { action: 'removed', filePath };
|
|
132
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive server-prompt loop for `seomi-ssh init`.
|
|
3
|
+
*
|
|
4
|
+
* Repeatedly asks the user to describe a server (role + connection params),
|
|
5
|
+
* then asks "add another?" — looping until the user declines. Supports 1..N
|
|
6
|
+
* servers, each addressable by a unique UPPER_SNAKE_CASE env prefix derived
|
|
7
|
+
* from its role (`prod` → `PROD`, `dev` → `DEV`, custom names normalized).
|
|
8
|
+
*
|
|
9
|
+
* Server model (one object per server):
|
|
10
|
+
* {
|
|
11
|
+
* role: string, // human role label as entered ('prod' / 'dev' / 'staging-eu')
|
|
12
|
+
* prefix: string, // unique UPPER_SNAKE_CASE env prefix ('PROD', 'STAGING_EU', 'PROD_2')
|
|
13
|
+
* host: string,
|
|
14
|
+
* user: string,
|
|
15
|
+
* port: string, // '' or numeric string; default '22'
|
|
16
|
+
* keyPath: string, // private key path, may contain leading '~'
|
|
17
|
+
* root: string, // optional remote working directory ('' when skipped)
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* The `@inquirer/prompts` module is loaded lazily and can be replaced through
|
|
21
|
+
* the `_prompts` test seam so the loop is unit-testable without a TTY.
|
|
22
|
+
*
|
|
23
|
+
* No external commands are run here — this module is pure I/O over prompts and
|
|
24
|
+
* has no horizontal lib dependencies (only the shared logger).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { logger } from './logger.mjs';
|
|
28
|
+
|
|
29
|
+
const ROLE_PROD = 'prod';
|
|
30
|
+
const ROLE_DEV = 'dev';
|
|
31
|
+
const ROLE_CUSTOM = '__custom__';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize an arbitrary role label into a bare UPPER_SNAKE_CASE token.
|
|
35
|
+
* Non-alphanumeric runs collapse to a single `_`; leading/trailing `_` are
|
|
36
|
+
* trimmed. The result is always usable as the `<PREFIX>` segment of a
|
|
37
|
+
* `SSH_<PREFIX>_HOST` env key (the `SSH_` literal guarantees the full key
|
|
38
|
+
* still starts with a letter even if the prefix begins with a digit).
|
|
39
|
+
*
|
|
40
|
+
* 'prod' → 'PROD', 'staging-eu' → 'STAGING_EU', ' my server!! ' → 'MY_SERVER'.
|
|
41
|
+
* Falls back to 'SERVER' when nothing usable remains.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizePrefix( role ) {
|
|
44
|
+
const cleaned = String( role || '' )
|
|
45
|
+
.toUpperCase()
|
|
46
|
+
.replace( /[^A-Z0-9]+/g, '_' )
|
|
47
|
+
.replace( /^_+|_+$/g, '' );
|
|
48
|
+
return cleaned || 'SERVER';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Ensure `base` is unique against `used` (a Set of already-taken prefixes).
|
|
53
|
+
* On collision, append `_2`, `_3`, … until free. Does NOT mutate `used`.
|
|
54
|
+
*/
|
|
55
|
+
export function uniquePrefix( base, used ) {
|
|
56
|
+
if ( ! used.has( base ) ) return base;
|
|
57
|
+
let n = 2;
|
|
58
|
+
while ( used.has( `${ base }_${ n }` ) ) n++;
|
|
59
|
+
return `${ base }_${ n }`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Prompt for a single server's role, returning the human label.
|
|
64
|
+
* `prod` / `dev` are offered directly; anything else is entered free-form.
|
|
65
|
+
*/
|
|
66
|
+
async function promptRole( prompts ) {
|
|
67
|
+
const choice = await prompts.select( {
|
|
68
|
+
message: 'Роль сервера',
|
|
69
|
+
default: ROLE_PROD,
|
|
70
|
+
choices: [
|
|
71
|
+
{ name: 'prod — продакшн', value: ROLE_PROD },
|
|
72
|
+
{ name: 'dev — разработка', value: ROLE_DEV },
|
|
73
|
+
{ name: 'другое (своё имя)', value: ROLE_CUSTOM },
|
|
74
|
+
],
|
|
75
|
+
} );
|
|
76
|
+
|
|
77
|
+
if ( choice !== ROLE_CUSTOM ) return choice;
|
|
78
|
+
|
|
79
|
+
const custom = await prompts.input( {
|
|
80
|
+
message: 'Имя роли (например staging, eu-prod)',
|
|
81
|
+
validate: ( v ) => ( String( v || '' ).trim().length > 0 ? true : 'Имя роли не может быть пустым' ),
|
|
82
|
+
} );
|
|
83
|
+
return String( custom ).trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Prompt for one server's connection parameters (role already known).
|
|
88
|
+
*/
|
|
89
|
+
async function promptServer( prompts, role, prefix ) {
|
|
90
|
+
logger.debug( `[server-prompt] collecting params for role=${ role } prefix=${ prefix }` );
|
|
91
|
+
|
|
92
|
+
const host = await prompts.input( {
|
|
93
|
+
message: `[${ role }] Host (домен или IP)`,
|
|
94
|
+
validate: ( v ) => ( String( v || '' ).trim().length > 0 ? true : 'Host обязателен' ),
|
|
95
|
+
} );
|
|
96
|
+
const user = await prompts.input( {
|
|
97
|
+
message: `[${ role }] SSH-пользователь`,
|
|
98
|
+
validate: ( v ) => ( String( v || '' ).trim().length > 0 ? true : 'Пользователь обязателен' ),
|
|
99
|
+
} );
|
|
100
|
+
const port = await prompts.input( {
|
|
101
|
+
message: `[${ role }] SSH-порт`,
|
|
102
|
+
default: '22',
|
|
103
|
+
} );
|
|
104
|
+
const keyPath = await prompts.input( {
|
|
105
|
+
message: `[${ role }] Путь к приватному ключу`,
|
|
106
|
+
default: '~/.ssh/id_ed25519',
|
|
107
|
+
} );
|
|
108
|
+
const root = await prompts.input( {
|
|
109
|
+
message: `[${ role }] Рабочая директория на сервере (необязательно)`,
|
|
110
|
+
default: '',
|
|
111
|
+
} );
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
role,
|
|
115
|
+
prefix,
|
|
116
|
+
host: String( host ).trim(),
|
|
117
|
+
user: String( user ).trim(),
|
|
118
|
+
port: String( port ).trim(),
|
|
119
|
+
keyPath: String( keyPath ).trim(),
|
|
120
|
+
root: String( root ).trim(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run the interactive loop. Returns the collected server list (possibly empty
|
|
126
|
+
* if the user declined the very first server).
|
|
127
|
+
*
|
|
128
|
+
* @param {object} [opts]
|
|
129
|
+
* @param {object} [opts._prompts] — test seam: replaces `@inquirer/prompts`.
|
|
130
|
+
* Must expose `select`, `input`, `confirm`.
|
|
131
|
+
* @returns {Promise<Array<object>>}
|
|
132
|
+
*/
|
|
133
|
+
export async function promptServers( { _prompts } = {} ) {
|
|
134
|
+
const prompts = _prompts || ( await import( '@inquirer/prompts' ) );
|
|
135
|
+
const servers = [];
|
|
136
|
+
const usedPrefixes = new Set();
|
|
137
|
+
|
|
138
|
+
let addMore = true;
|
|
139
|
+
while ( addMore ) {
|
|
140
|
+
const role = await promptRole( prompts );
|
|
141
|
+
const prefix = uniquePrefix( normalizePrefix( role ), usedPrefixes );
|
|
142
|
+
usedPrefixes.add( prefix );
|
|
143
|
+
|
|
144
|
+
const server = await promptServer( prompts, role, prefix );
|
|
145
|
+
servers.push( server );
|
|
146
|
+
logger.success( `[server-prompt] добавлен сервер «${ role }» → env-префикс SSH_${ prefix }_*` );
|
|
147
|
+
|
|
148
|
+
addMore = await prompts.confirm( {
|
|
149
|
+
message: 'Добавить ещё один сервер?',
|
|
150
|
+
default: false,
|
|
151
|
+
} );
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
logger.debug( `[server-prompt] total servers=${ servers.length }` );
|
|
155
|
+
return servers;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 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.
|
|
163
|
+
*/
|
|
164
|
+
export function toEnvUpdates( servers ) {
|
|
165
|
+
const updates = {};
|
|
166
|
+
for ( const s of servers ) {
|
|
167
|
+
const p = s.prefix;
|
|
168
|
+
updates[ `SSH_${ p }_HOST` ] = s.host;
|
|
169
|
+
updates[ `SSH_${ p }_USER` ] = s.user;
|
|
170
|
+
if ( s.port ) updates[ `SSH_${ p }_PORT` ] = s.port;
|
|
171
|
+
updates[ `SSH_${ p }_KEY` ] = s.keyPath;
|
|
172
|
+
if ( s.root ) updates[ `SSH_${ p }_ROOT` ] = s.root;
|
|
173
|
+
}
|
|
174
|
+
updates.SSH_SERVERS = servers.map( ( s ) => s.prefix ).join( ',' );
|
|
175
|
+
return updates;
|
|
176
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH key wizard for `seomi-ssh init`.
|
|
3
|
+
*
|
|
4
|
+
* Sets up passwordless SSH access to a remote host in a self-contained
|
|
5
|
+
* strategy chain:
|
|
6
|
+
*
|
|
7
|
+
* 1. Keygen — generate ed25519 key if missing (`ssh-keygen -N ''`), or
|
|
8
|
+
* reuse an existing one. Empty passphrase is intentional:
|
|
9
|
+
* the agent needs non-interactive auth, the user can encrypt
|
|
10
|
+
* the key later if they want.
|
|
11
|
+
* 2. Copy — `ssh-copy-id` (asks for the SSH password ONCE), or a
|
|
12
|
+
* portable `ssh` fallback that pipes the public key through
|
|
13
|
+
* stdin into ~/.ssh/authorized_keys (deduped).
|
|
14
|
+
* 3. Verify — `ssh -o BatchMode=yes ... 'echo ok'`. BatchMode disables
|
|
15
|
+
* password prompts, so a non-zero exit reliably means
|
|
16
|
+
* key-auth did not take.
|
|
17
|
+
* 4. Fallback — if verify fails (typical on shared hosts that only accept
|
|
18
|
+
* keys via a control panel), return a `manualHint` string with
|
|
19
|
+
* the .pub content and a how-to.
|
|
20
|
+
*
|
|
21
|
+
* This module is self-contained on purpose — it does NOT import a shared spawn
|
|
22
|
+
* helper from a sibling lib module (that would create a horizontal lib→lib
|
|
23
|
+
* dependency the architecture forbids). It carries its own thin spawn() wrapper.
|
|
24
|
+
*
|
|
25
|
+
* Private key material is NEVER logged — only the public key appears in hints.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn } from 'node:child_process';
|
|
29
|
+
import { existsSync } from 'node:fs';
|
|
30
|
+
import { readFile, mkdir, access } from 'node:fs/promises';
|
|
31
|
+
import { homedir } from 'node:os';
|
|
32
|
+
import { resolve as resolvePath, dirname } from 'node:path';
|
|
33
|
+
import { logger } from './logger.mjs';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a path with a leading `~` to an absolute path using the user's
|
|
37
|
+
* home directory. Leaving the literal `~` in place breaks on Windows where
|
|
38
|
+
* the shell does not expand it.
|
|
39
|
+
*/
|
|
40
|
+
function expandHome( p ) {
|
|
41
|
+
if ( ! p ) return p;
|
|
42
|
+
if ( p === '~' ) return homedir();
|
|
43
|
+
if ( p.startsWith( '~/' ) || p.startsWith( '~\\' ) ) {
|
|
44
|
+
return resolvePath( homedir(), p.slice( 2 ) );
|
|
45
|
+
}
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Spawn a child process and resolve with { code, stdout, stderr }.
|
|
51
|
+
*
|
|
52
|
+
* Custom opts (not real spawn options, stripped before the spawn call):
|
|
53
|
+
* - `interactive: true` → `stdio: ['inherit', 'pipe', 'inherit']`. Used
|
|
54
|
+
* for `ssh-copy-id`, which needs the user's terminal for the password
|
|
55
|
+
* prompt but whose stdout we still want to capture in tests.
|
|
56
|
+
* - `input: <string>` → `stdio: ['pipe', 'inherit', 'inherit']`. Used
|
|
57
|
+
* for the ssh-pipe fallback: we write the public key to stdin, the
|
|
58
|
+
* remote shell appends it to authorized_keys.
|
|
59
|
+
*
|
|
60
|
+
* Without either flag the default is fully-piped stdio (stdout/stderr
|
|
61
|
+
* captured, stdin closed) — used for `ssh-keygen` and the verify step.
|
|
62
|
+
*/
|
|
63
|
+
function exec( cmd, args, opts = {} ) {
|
|
64
|
+
const { interactive, input, ...spawnOpts } = opts;
|
|
65
|
+
if ( interactive ) {
|
|
66
|
+
spawnOpts.stdio = [ 'inherit', 'pipe', 'inherit' ];
|
|
67
|
+
logger.debug( `ssh-key-setup exec: interactive stdio for ${ cmd }` );
|
|
68
|
+
} else if ( typeof input === 'string' ) {
|
|
69
|
+
spawnOpts.stdio = [ 'pipe', 'inherit', 'inherit' ];
|
|
70
|
+
logger.debug( `ssh-key-setup exec: pipe-stdin stdio for ${ cmd }` );
|
|
71
|
+
}
|
|
72
|
+
return new Promise( ( resolve ) => {
|
|
73
|
+
const child = spawn( cmd, args, { shell: false, windowsHide: true, ...spawnOpts } );
|
|
74
|
+
let stdout = '';
|
|
75
|
+
let stderr = '';
|
|
76
|
+
child.stdout?.on( 'data', ( d ) => { stdout += d.toString(); } );
|
|
77
|
+
child.stderr?.on( 'data', ( d ) => { stderr += d.toString(); } );
|
|
78
|
+
child.on( 'error', ( err ) => resolve( { code: -1, stdout, stderr: stderr + err.message } ) );
|
|
79
|
+
child.on( 'close', ( code ) => resolve( { code: code ?? 0, stdout, stderr } ) );
|
|
80
|
+
if ( typeof input === 'string' && child.stdin ) {
|
|
81
|
+
child.stdin.write( input );
|
|
82
|
+
child.stdin.end();
|
|
83
|
+
}
|
|
84
|
+
} );
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function portArgs( sshPort ) {
|
|
88
|
+
return sshPort ? [ '-p', String( sshPort ) ] : [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Portable ssh-copy-id replacement: pipe the public key through ssh stdin
|
|
93
|
+
* into ~/.ssh/authorized_keys on the remote host. Deduplicates so repeat
|
|
94
|
+
* runs don't keep appending the same key.
|
|
95
|
+
*/
|
|
96
|
+
const REMOTE_APPEND_SCRIPT =
|
|
97
|
+
'mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && '
|
|
98
|
+
+ 'chmod 600 ~/.ssh/authorized_keys && KEY="$(cat)" && '
|
|
99
|
+
+ 'grep -qxF "$KEY" ~/.ssh/authorized_keys || printf \'%s\\n\' "$KEY" >> ~/.ssh/authorized_keys';
|
|
100
|
+
|
|
101
|
+
function buildManualHint( { pubKeyContent, sshUser, sshHost, sshPort, keyPath } ) {
|
|
102
|
+
const portFragment = sshPort ? ` -p ${ sshPort }` : '';
|
|
103
|
+
return [
|
|
104
|
+
'Не удалось автоматически добавить SSH-ключ — добавьте его через панель хостинга (cPanel / Beget / DirectAdmin / ISPmanager и т.п.) или вручную в ~/.ssh/authorized_keys на сервере.',
|
|
105
|
+
'',
|
|
106
|
+
'Содержимое публичного ключа:',
|
|
107
|
+
'',
|
|
108
|
+
pubKeyContent,
|
|
109
|
+
'',
|
|
110
|
+
'После добавления повторите `seomi-ssh init` или проверьте подключение вручную:',
|
|
111
|
+
` ssh -i ${ keyPath }${ portFragment } ${ sshUser }@${ sshHost }`,
|
|
112
|
+
].join( '\n' );
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Set up key-based SSH auth for a remote host.
|
|
117
|
+
*
|
|
118
|
+
* @param {Object} cfg
|
|
119
|
+
* @param {string} cfg.sshHost Remote host (e.g. 'prod.example.com').
|
|
120
|
+
* @param {string} cfg.sshUser Remote SSH user.
|
|
121
|
+
* @param {string} [cfg.sshPort=''] Remote SSH port. Blank = ssh default (22).
|
|
122
|
+
* @param {string} [cfg.keyPath] Absolute path to the private key. `~`
|
|
123
|
+
* is expanded. Default: ~/.ssh/id_ed25519.
|
|
124
|
+
* @param {string} [cfg.comment] Key comment. Default: `ai-agent@<host>`.
|
|
125
|
+
* @returns {Promise<{
|
|
126
|
+
* keyPath: string,
|
|
127
|
+
* pubKeyPath: string,
|
|
128
|
+
* pubKeyContent: string,
|
|
129
|
+
* keygenAction: 'created' | 'reused',
|
|
130
|
+
* copyAction: 'ssh-copy-id' | 'ssh-fallback' | 'failed' | 'skipped',
|
|
131
|
+
* verified: boolean,
|
|
132
|
+
* manualHint: string | null,
|
|
133
|
+
* }>}
|
|
134
|
+
*/
|
|
135
|
+
export async function ensureSshKey( cfg ) {
|
|
136
|
+
const { sshHost, sshUser } = cfg;
|
|
137
|
+
const sshPort = cfg.sshPort || '';
|
|
138
|
+
const keyPath = expandHome( cfg.keyPath ) || resolvePath( homedir(), '.ssh', 'id_ed25519' );
|
|
139
|
+
const pubKeyPath = keyPath + '.pub';
|
|
140
|
+
const comment = cfg.comment || `ai-agent@${ sshHost }`;
|
|
141
|
+
const sshTarget = `${ sshUser }@${ sshHost }`;
|
|
142
|
+
|
|
143
|
+
logger.step( 'SSH key setup' );
|
|
144
|
+
logger.debug( `ssh-key-setup: target=${ sshTarget } port=${ sshPort || '22' } keyPath=${ keyPath }` );
|
|
145
|
+
|
|
146
|
+
// --- 1. Keygen --------------------------------------------------------
|
|
147
|
+
const keyExists = existsSync( keyPath );
|
|
148
|
+
logger.debug( `keygen: keyPath=${ keyPath } exists=${ keyExists }` );
|
|
149
|
+
let keygenAction;
|
|
150
|
+
if ( keyExists ) {
|
|
151
|
+
try {
|
|
152
|
+
await access( keyPath );
|
|
153
|
+
} catch ( err ) {
|
|
154
|
+
throw new Error( `Private key exists at ${ keyPath } but cannot be read: ${ err.message }` );
|
|
155
|
+
}
|
|
156
|
+
keygenAction = 'reused';
|
|
157
|
+
} else {
|
|
158
|
+
await mkdir( dirname( keyPath ), { recursive: true } );
|
|
159
|
+
const r = await _internals.exec( 'ssh-keygen', [
|
|
160
|
+
'-t', 'ed25519',
|
|
161
|
+
'-N', '',
|
|
162
|
+
'-C', comment,
|
|
163
|
+
'-f', keyPath,
|
|
164
|
+
] );
|
|
165
|
+
if ( r.code !== 0 ) {
|
|
166
|
+
throw new Error( `ssh-keygen failed (exit ${ r.code }): ${ r.stderr.trim() || 'is ssh-keygen on PATH?' }` );
|
|
167
|
+
}
|
|
168
|
+
keygenAction = 'created';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const pubKeyContent = ( await readFile( pubKeyPath, 'utf8' ) ).trim();
|
|
172
|
+
|
|
173
|
+
// --- 2. Copy ----------------------------------------------------------
|
|
174
|
+
logger.info( 'Copying public key via ssh-copy-id' );
|
|
175
|
+
const port = portArgs( sshPort );
|
|
176
|
+
const copyResult = await _internals.exec(
|
|
177
|
+
'ssh-copy-id',
|
|
178
|
+
[ '-i', pubKeyPath, ...port, sshTarget ],
|
|
179
|
+
{ interactive: true },
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
let copyAction;
|
|
183
|
+
if ( copyResult.code === 0 ) {
|
|
184
|
+
copyAction = 'ssh-copy-id';
|
|
185
|
+
} else if ( copyResult.code === -1 ) {
|
|
186
|
+
// ssh-copy-id binary not on PATH (typical on Windows OpenSSH).
|
|
187
|
+
logger.warn( 'ssh-copy-id missing — using ssh fallback' );
|
|
188
|
+
const fb = await _internals.exec(
|
|
189
|
+
'ssh',
|
|
190
|
+
[ ...port, sshTarget, REMOTE_APPEND_SCRIPT ],
|
|
191
|
+
{ input: pubKeyContent + '\n' },
|
|
192
|
+
);
|
|
193
|
+
copyAction = fb.code === 0 ? 'ssh-fallback' : 'failed';
|
|
194
|
+
if ( fb.code !== 0 ) {
|
|
195
|
+
logger.warn( `ssh fallback copy failed (exit ${ fb.code })` );
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
logger.warn( `ssh-copy-id failed (exit ${ copyResult.code }): ${ copyResult.stderr.trim() }` );
|
|
199
|
+
copyAction = 'failed';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- 3. Verify --------------------------------------------------------
|
|
203
|
+
const verifyArgs = [
|
|
204
|
+
'-o', 'BatchMode=yes',
|
|
205
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
206
|
+
'-o', 'ConnectTimeout=10',
|
|
207
|
+
'-i', keyPath,
|
|
208
|
+
...port,
|
|
209
|
+
sshTarget,
|
|
210
|
+
'echo ok',
|
|
211
|
+
];
|
|
212
|
+
logger.debug( `verify: command=ssh ${ verifyArgs.join( ' ' ) }` );
|
|
213
|
+
const verifyResult = await _internals.exec( 'ssh', verifyArgs );
|
|
214
|
+
const verified = verifyResult.code === 0 && verifyResult.stdout.includes( 'ok' );
|
|
215
|
+
|
|
216
|
+
// --- 4. Result --------------------------------------------------------
|
|
217
|
+
let manualHint = null;
|
|
218
|
+
if ( verified ) {
|
|
219
|
+
logger.success( 'SSH key copied and verified' );
|
|
220
|
+
} else {
|
|
221
|
+
logger.warn( 'SSH key copy verified=false; printing manual hint' );
|
|
222
|
+
manualHint = buildManualHint( { pubKeyContent, sshUser, sshHost, sshPort, keyPath } );
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
keyPath,
|
|
227
|
+
pubKeyPath,
|
|
228
|
+
pubKeyContent,
|
|
229
|
+
keygenAction,
|
|
230
|
+
copyAction,
|
|
231
|
+
verified,
|
|
232
|
+
manualHint,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const _internals = { exec, buildManualHint, expandHome, REMOTE_APPEND_SCRIPT };
|