kodu 1.1.13 → 1.1.15
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/AGENTS.md +184 -199
- package/README.md +32 -3
- package/dist/src/app.module.js +2 -0
- package/dist/src/app.module.js.map +1 -1
- package/dist/src/commands/init/init.command.js +16 -0
- package/dist/src/commands/init/init.command.js.map +1 -1
- package/dist/src/commands/ops/ops.command.d.ts +4 -0
- package/dist/src/commands/ops/ops.command.js +39 -0
- package/dist/src/commands/ops/ops.command.js.map +1 -0
- package/dist/src/commands/ops/ops.module.d.ts +2 -0
- package/dist/src/commands/ops/ops.module.js +33 -0
- package/dist/src/commands/ops/ops.module.js.map +1 -0
- package/dist/src/commands/ops/ops.types.d.ts +13 -0
- package/dist/src/commands/ops/ops.types.js +12 -0
- package/dist/src/commands/ops/ops.types.js.map +1 -0
- package/dist/src/commands/ops/ops.utils.d.ts +13 -0
- package/dist/src/commands/ops/ops.utils.js +117 -0
- package/dist/src/commands/ops/ops.utils.js.map +1 -0
- package/dist/src/commands/ops/subcommands/ops-env.command.d.ts +17 -0
- package/dist/src/commands/ops/subcommands/ops-env.command.js +109 -0
- package/dist/src/commands/ops/subcommands/ops-env.command.js.map +1 -0
- package/dist/src/commands/ops/subcommands/ops-routes.command.d.ts +18 -0
- package/dist/src/commands/ops/subcommands/ops-routes.command.js +166 -0
- package/dist/src/commands/ops/subcommands/ops-routes.command.js.map +1 -0
- package/dist/src/commands/ops/subcommands/ops-service.command.d.ts +16 -0
- package/dist/src/commands/ops/subcommands/ops-service.command.js +128 -0
- package/dist/src/commands/ops/subcommands/ops-service.command.js.map +1 -0
- package/dist/src/commands/ops/subcommands/ops-sysinfo.command.d.ts +9 -0
- package/dist/src/commands/ops/subcommands/ops-sysinfo.command.js +60 -0
- package/dist/src/commands/ops/subcommands/ops-sysinfo.command.js.map +1 -0
- package/dist/src/commands/pack/pack.command.js +1 -2
- package/dist/src/commands/pack/pack.command.js.map +1 -1
- package/dist/src/core/config/config.schema.d.ts +28 -0
- package/dist/src/core/config/config.schema.js +19 -0
- package/dist/src/core/config/config.schema.js.map +1 -1
- package/dist/src/core/file-system/fs.service.d.ts +4 -1
- package/dist/src/core/file-system/fs.service.js +57 -21
- package/dist/src/core/file-system/fs.service.js.map +1 -1
- package/dist/src/shared/constants.d.ts +1 -0
- package/dist/src/shared/constants.js +2 -1
- package/dist/src/shared/constants.js.map +1 -1
- package/dist/src/shared/ssh/ssh.module.d.ts +2 -0
- package/dist/src/shared/ssh/ssh.module.js +21 -0
- package/dist/src/shared/ssh/ssh.module.js.map +1 -0
- package/dist/src/shared/ssh/ssh.service.d.ts +11 -0
- package/dist/src/shared/ssh/ssh.service.js +53 -0
- package/dist/src/shared/ssh/ssh.service.js.map +1 -0
- package/dist/src/shared/tokenizer/tokenizer.service.js +1 -1
- package/dist/src/shared/tokenizer/tokenizer.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/plans/2026-03-01-agentops-design.md +194 -0
- package/docs/plans/2026-03-01-agentops-implementation.md +358 -0
- package/kodu.json +15 -0
- package/kodu.schema.json +59 -0
- package/package.json +1 -1
- package/src/app.module.ts +2 -0
- package/src/commands/init/init.command.ts +16 -0
- package/src/commands/ops/ops.command.ts +30 -0
- package/src/commands/ops/ops.module.ts +20 -0
- package/src/commands/ops/ops.types.ts +24 -0
- package/src/commands/ops/ops.utils.ts +156 -0
- package/src/commands/ops/subcommands/ops-env.command.ts +121 -0
- package/src/commands/ops/subcommands/ops-routes.command.ts +185 -0
- package/src/commands/ops/subcommands/ops-service.command.ts +154 -0
- package/src/commands/ops/subcommands/ops-sysinfo.command.ts +53 -0
- package/src/commands/pack/pack.command.ts +1 -2
- package/src/core/config/config.schema.ts +23 -0
- package/src/core/file-system/fs.service.ts +72 -23
- package/src/shared/constants.ts +1 -0
- package/src/shared/ssh/ssh.module.ts +8 -0
- package/src/shared/ssh/ssh.service.ts +61 -0
- package/src/shared/tokenizer/tokenizer.service.ts +2 -2
- package/.cursor/commands/openspec-apply.md +0 -23
- package/.cursor/commands/openspec-archive.md +0 -27
- package/.cursor/commands/openspec-proposal.md +0 -28
- package/.windsurf/workflows/openspec-apply.md +0 -21
- package/.windsurf/workflows/openspec-archive.md +0 -25
- package/.windsurf/workflows/openspec-proposal.md +0 -26
- package/openspec/AGENTS.md +0 -456
- package/openspec/changes/archive/2026-01-26-translate-project-to-english/design.md +0 -30
- package/openspec/changes/archive/2026-01-26-translate-project-to-english/proposal.md +0 -17
- package/openspec/changes/archive/2026-01-26-translate-project-to-english/specs/ai/spec.md +0 -26
- package/openspec/changes/archive/2026-01-26-translate-project-to-english/specs/cleaner/spec.md +0 -26
- package/openspec/changes/archive/2026-01-26-translate-project-to-english/specs/config/spec.md +0 -22
- package/openspec/changes/archive/2026-01-26-translate-project-to-english/specs/ui/spec.md +0 -33
- package/openspec/changes/archive/2026-01-26-translate-project-to-english/tasks.md +0 -33
- package/openspec/project.md +0 -72
- package/openspec/specs/cleaner/spec.md +0 -31
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Command, CommandRunner } from 'nest-commander';
|
|
2
|
+
import { printJson } from './ops.utils';
|
|
3
|
+
import { OpsEnvCommand } from './subcommands/ops-env.command';
|
|
4
|
+
import { OpsRoutesCommand } from './subcommands/ops-routes.command';
|
|
5
|
+
import { OpsServiceCommand } from './subcommands/ops-service.command';
|
|
6
|
+
import { OpsSysinfoCommand } from './subcommands/ops-sysinfo.command';
|
|
7
|
+
|
|
8
|
+
@Command({
|
|
9
|
+
name: 'ops',
|
|
10
|
+
description: 'Remote server operations for agents',
|
|
11
|
+
subCommands: [
|
|
12
|
+
OpsSysinfoCommand,
|
|
13
|
+
OpsEnvCommand,
|
|
14
|
+
OpsRoutesCommand,
|
|
15
|
+
OpsServiceCommand,
|
|
16
|
+
],
|
|
17
|
+
})
|
|
18
|
+
export class OpsCommand extends CommandRunner {
|
|
19
|
+
async run(): Promise<void> {
|
|
20
|
+
printJson(
|
|
21
|
+
{
|
|
22
|
+
status: 'error',
|
|
23
|
+
code: 'VALIDATION_ERROR',
|
|
24
|
+
error: 'Missing ops subcommand',
|
|
25
|
+
},
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule } from '../../core/config/config.module';
|
|
3
|
+
import { SshModule } from '../../shared/ssh/ssh.module';
|
|
4
|
+
import { OpsCommand } from './ops.command';
|
|
5
|
+
import { OpsEnvCommand } from './subcommands/ops-env.command';
|
|
6
|
+
import { OpsRoutesCommand } from './subcommands/ops-routes.command';
|
|
7
|
+
import { OpsServiceCommand } from './subcommands/ops-service.command';
|
|
8
|
+
import { OpsSysinfoCommand } from './subcommands/ops-sysinfo.command';
|
|
9
|
+
|
|
10
|
+
@Module({
|
|
11
|
+
imports: [ConfigModule, SshModule],
|
|
12
|
+
providers: [
|
|
13
|
+
OpsCommand,
|
|
14
|
+
OpsSysinfoCommand,
|
|
15
|
+
OpsEnvCommand,
|
|
16
|
+
OpsRoutesCommand,
|
|
17
|
+
OpsServiceCommand,
|
|
18
|
+
],
|
|
19
|
+
})
|
|
20
|
+
export class OpsModule {}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ServerConfig } from '../../core/config/config.schema';
|
|
2
|
+
|
|
3
|
+
export type OpsErrorCode =
|
|
4
|
+
| 'CLI_ERROR'
|
|
5
|
+
| 'CONFIG_ERROR'
|
|
6
|
+
| 'NOT_FOUND'
|
|
7
|
+
| 'VALIDATION_ERROR';
|
|
8
|
+
|
|
9
|
+
export type ResolvedServerConfig = ServerConfig & {
|
|
10
|
+
sshKeyPath: string;
|
|
11
|
+
paths?: {
|
|
12
|
+
apps: string;
|
|
13
|
+
caddy?: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class OpsCliError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
public readonly code: OpsErrorCode,
|
|
20
|
+
message: string,
|
|
21
|
+
) {
|
|
22
|
+
super(message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { KoduConfig, ServerConfig } from '../../core/config/config.schema';
|
|
4
|
+
import type { SshResult } from '../../shared/ssh/ssh.service';
|
|
5
|
+
import {
|
|
6
|
+
OpsCliError,
|
|
7
|
+
type OpsErrorCode,
|
|
8
|
+
type ResolvedServerConfig,
|
|
9
|
+
} from './ops.types';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_APPS_PATH = '/var/agent-apps';
|
|
12
|
+
|
|
13
|
+
export function printJson(payload: unknown, isError = false): void {
|
|
14
|
+
const line = JSON.stringify(payload);
|
|
15
|
+
if (isError) {
|
|
16
|
+
console.error(line);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
console.log(line);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function printCliError(error: unknown): void {
|
|
23
|
+
const cliError = toCliError(error);
|
|
24
|
+
printJson(
|
|
25
|
+
{
|
|
26
|
+
status: 'error',
|
|
27
|
+
code: cliError.code,
|
|
28
|
+
error: cliError.message,
|
|
29
|
+
},
|
|
30
|
+
true,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function printSshError(result: SshResult, command: string): void {
|
|
35
|
+
printJson({
|
|
36
|
+
status: 'error',
|
|
37
|
+
code: result.exitCode,
|
|
38
|
+
stderr: result.stderr || result.error || 'Unknown SSH error',
|
|
39
|
+
command,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resolveServerOrThrow(
|
|
44
|
+
config: KoduConfig,
|
|
45
|
+
alias: string,
|
|
46
|
+
): Promise<ResolvedServerConfig> {
|
|
47
|
+
const servers = config.ops?.servers;
|
|
48
|
+
if (!servers) {
|
|
49
|
+
throw new OpsCliError(
|
|
50
|
+
'CONFIG_ERROR',
|
|
51
|
+
'ops.servers not configured in kodu.json',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const server = servers[alias];
|
|
56
|
+
if (!server) {
|
|
57
|
+
throw new OpsCliError(
|
|
58
|
+
'VALIDATION_ERROR',
|
|
59
|
+
`Server alias '${alias}' not found in kodu.json`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const resolved = normalizeServer(server);
|
|
64
|
+
await assertSshKeyExists(resolved.sshKeyPath);
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveAppsPath(server: ResolvedServerConfig): string {
|
|
69
|
+
return server.paths?.apps ?? DEFAULT_APPS_PATH;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveCaddyPath(server: ResolvedServerConfig): string {
|
|
73
|
+
return (
|
|
74
|
+
server.paths?.caddy ?? path.posix.join(resolveAppsPath(server), 'caddy')
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function shellQuote(value: string): string {
|
|
79
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ensureEnvKey(key: string | undefined): string {
|
|
83
|
+
if (!key) {
|
|
84
|
+
throw new OpsCliError('VALIDATION_ERROR', 'Option --key is required');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
88
|
+
throw new OpsCliError(
|
|
89
|
+
'VALIDATION_ERROR',
|
|
90
|
+
'Invalid env key format. Allowed: [A-Za-z_][A-Za-z0-9_]*',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return key;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function ensureRequired(
|
|
98
|
+
value: string | undefined,
|
|
99
|
+
name: string,
|
|
100
|
+
): string {
|
|
101
|
+
if (!value) {
|
|
102
|
+
throw new OpsCliError('VALIDATION_ERROR', `Option --${name} is required`);
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function ensureAction<T extends string>(
|
|
108
|
+
value: string,
|
|
109
|
+
allowed: readonly T[],
|
|
110
|
+
context: string,
|
|
111
|
+
): T {
|
|
112
|
+
if (!allowed.includes(value as T)) {
|
|
113
|
+
throw new OpsCliError(
|
|
114
|
+
'VALIDATION_ERROR',
|
|
115
|
+
`Unsupported ${context}: '${value}'. Allowed: ${allowed.join(', ')}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return value as T;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeServer(server: ServerConfig): ResolvedServerConfig {
|
|
122
|
+
const apps = server.paths?.apps ?? DEFAULT_APPS_PATH;
|
|
123
|
+
return {
|
|
124
|
+
...server,
|
|
125
|
+
sshKeyPath: path.isAbsolute(server.sshKeyPath)
|
|
126
|
+
? server.sshKeyPath
|
|
127
|
+
: path.resolve(process.cwd(), server.sshKeyPath),
|
|
128
|
+
paths: {
|
|
129
|
+
apps,
|
|
130
|
+
caddy: server.paths?.caddy,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function assertSshKeyExists(sshKeyPath: string): Promise<void> {
|
|
136
|
+
try {
|
|
137
|
+
await access(sshKeyPath);
|
|
138
|
+
} catch {
|
|
139
|
+
throw new OpsCliError(
|
|
140
|
+
'VALIDATION_ERROR',
|
|
141
|
+
`SSH key file not found or inaccessible: ${sshKeyPath}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toCliError(error: unknown): { code: OpsErrorCode; message: string } {
|
|
147
|
+
if (error instanceof OpsCliError) {
|
|
148
|
+
return { code: error.code, message: error.message };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (error instanceof Error) {
|
|
152
|
+
return { code: 'CLI_ERROR', message: error.message };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { code: 'CLI_ERROR', message: 'Unknown CLI error' };
|
|
156
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { CommandRunner, Option, SubCommand } from 'nest-commander';
|
|
3
|
+
import { ConfigService } from '../../../core/config/config.service';
|
|
4
|
+
import { SshService } from '../../../shared/ssh/ssh.service';
|
|
5
|
+
import {
|
|
6
|
+
ensureAction,
|
|
7
|
+
ensureEnvKey,
|
|
8
|
+
ensureRequired,
|
|
9
|
+
printCliError,
|
|
10
|
+
printJson,
|
|
11
|
+
printSshError,
|
|
12
|
+
resolveAppsPath,
|
|
13
|
+
resolveServerOrThrow,
|
|
14
|
+
shellQuote,
|
|
15
|
+
} from '../ops.utils';
|
|
16
|
+
|
|
17
|
+
type OpsEnvAction = 'get' | 'set' | 'unset';
|
|
18
|
+
|
|
19
|
+
type OpsEnvOptions = {
|
|
20
|
+
key?: string;
|
|
21
|
+
val?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
@SubCommand({
|
|
25
|
+
name: 'env',
|
|
26
|
+
description: 'Manage remote .env files',
|
|
27
|
+
arguments: '<alias> <action> <project>',
|
|
28
|
+
})
|
|
29
|
+
export class OpsEnvCommand extends CommandRunner {
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly configService: ConfigService,
|
|
32
|
+
private readonly sshService: SshService,
|
|
33
|
+
) {
|
|
34
|
+
super();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Option({ flags: '--key <key>', description: 'Environment key' })
|
|
38
|
+
parseKey(value: string): string {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Option({ flags: '--val <value>', description: 'Environment value' })
|
|
43
|
+
parseVal(value: string): string {
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async run(
|
|
48
|
+
passedParams: string[],
|
|
49
|
+
options: OpsEnvOptions = {},
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const [alias, rawAction, project] = passedParams;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const action = ensureAction<OpsEnvAction>(
|
|
55
|
+
rawAction,
|
|
56
|
+
['get', 'set', 'unset'],
|
|
57
|
+
'env action',
|
|
58
|
+
);
|
|
59
|
+
const server = await resolveServerOrThrow(
|
|
60
|
+
this.configService.getConfig(),
|
|
61
|
+
alias,
|
|
62
|
+
);
|
|
63
|
+
const envPath = path.posix.join(resolveAppsPath(server), project, '.env');
|
|
64
|
+
|
|
65
|
+
const command = this.buildCommand(action, envPath, options);
|
|
66
|
+
const result = await this.sshService.execute(server, command);
|
|
67
|
+
|
|
68
|
+
if (!result.success) {
|
|
69
|
+
printSshError(result, command);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (action === 'get') {
|
|
74
|
+
printJson({ status: 'ok', data: { content: result.stdout } });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
printJson({ status: 'ok', message: 'Env updated' });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
printCliError(error);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private buildCommand(
|
|
86
|
+
action: OpsEnvAction,
|
|
87
|
+
envPath: string,
|
|
88
|
+
options: OpsEnvOptions,
|
|
89
|
+
): string {
|
|
90
|
+
const quotedPath = shellQuote(envPath);
|
|
91
|
+
|
|
92
|
+
if (action === 'get') {
|
|
93
|
+
return `cat ${quotedPath}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const key = ensureEnvKey(options.key);
|
|
97
|
+
const quotedKey = shellQuote(key);
|
|
98
|
+
|
|
99
|
+
if (action === 'set') {
|
|
100
|
+
const val = ensureRequired(options.val, 'val');
|
|
101
|
+
const quotedVal = shellQuote(val);
|
|
102
|
+
return [
|
|
103
|
+
`ENV_FILE=${quotedPath}`,
|
|
104
|
+
`KEY=${quotedKey}`,
|
|
105
|
+
`VAL=${quotedVal}`,
|
|
106
|
+
'mkdir -p "$(dirname "$ENV_FILE")"',
|
|
107
|
+
'touch "$ENV_FILE"',
|
|
108
|
+
'awk -v k="$KEY" -v v="$VAL" \'BEGIN{found=0} $0 ~ "^" k "=" { print k "=" v; found=1; next } { print } END { if (!found) print k "=" v }\' "$ENV_FILE" > "$ENV_FILE.tmp"',
|
|
109
|
+
'mv "$ENV_FILE.tmp" "$ENV_FILE"',
|
|
110
|
+
].join(' && ');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
`ENV_FILE=${quotedPath}`,
|
|
115
|
+
`KEY=${quotedKey}`,
|
|
116
|
+
'if [ ! -f "$ENV_FILE" ]; then exit 0; fi',
|
|
117
|
+
'grep -v "^$KEY=" "$ENV_FILE" > "$ENV_FILE.tmp"',
|
|
118
|
+
'mv "$ENV_FILE.tmp" "$ENV_FILE"',
|
|
119
|
+
].join(' && ');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { CommandRunner, Option, SubCommand } from 'nest-commander';
|
|
3
|
+
import { ConfigService } from '../../../core/config/config.service';
|
|
4
|
+
import { SshService } from '../../../shared/ssh/ssh.service';
|
|
5
|
+
import {
|
|
6
|
+
ensureAction,
|
|
7
|
+
ensureRequired,
|
|
8
|
+
printCliError,
|
|
9
|
+
printJson,
|
|
10
|
+
printSshError,
|
|
11
|
+
resolveCaddyPath,
|
|
12
|
+
resolveServerOrThrow,
|
|
13
|
+
shellQuote,
|
|
14
|
+
} from '../ops.utils';
|
|
15
|
+
|
|
16
|
+
type OpsRoutesAction = 'list' | 'add' | 'remove' | 'update';
|
|
17
|
+
|
|
18
|
+
type OpsRoutesOptions = {
|
|
19
|
+
domain?: string;
|
|
20
|
+
upstream?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
@SubCommand({
|
|
24
|
+
name: 'routes',
|
|
25
|
+
description: 'Manage remote Caddy routes',
|
|
26
|
+
arguments: '<alias> <action>',
|
|
27
|
+
})
|
|
28
|
+
export class OpsRoutesCommand extends CommandRunner {
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly configService: ConfigService,
|
|
31
|
+
private readonly sshService: SshService,
|
|
32
|
+
) {
|
|
33
|
+
super();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Option({ flags: '--domain <domain>', description: 'Domain name' })
|
|
37
|
+
parseDomain(value: string): string {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Option({ flags: '--upstream <upstream>', description: 'Upstream host:port' })
|
|
42
|
+
parseUpstream(value: string): string {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async run(
|
|
47
|
+
passedParams: string[],
|
|
48
|
+
options: OpsRoutesOptions = {},
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const [alias, rawAction] = passedParams;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const action = ensureAction<OpsRoutesAction>(
|
|
54
|
+
rawAction,
|
|
55
|
+
['list', 'add', 'remove', 'update'],
|
|
56
|
+
'routes action',
|
|
57
|
+
);
|
|
58
|
+
const server = await resolveServerOrThrow(
|
|
59
|
+
this.configService.getConfig(),
|
|
60
|
+
alias,
|
|
61
|
+
);
|
|
62
|
+
const caddyRoot = resolveCaddyPath(server);
|
|
63
|
+
const caddyfilePath = path.posix.join(caddyRoot, 'data', 'Caddyfile');
|
|
64
|
+
|
|
65
|
+
if (action === 'list') {
|
|
66
|
+
const command = `cat ${shellQuote(caddyfilePath)}`;
|
|
67
|
+
const result = await this.sshService.execute(server, command);
|
|
68
|
+
if (!result.success) {
|
|
69
|
+
printSshError(result, command);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
printJson({ status: 'ok', data: { caddyfile: result.stdout } });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const domain = ensureRequired(options.domain, 'domain');
|
|
78
|
+
const upstream =
|
|
79
|
+
action === 'add' || action === 'update'
|
|
80
|
+
? ensureRequired(options.upstream, 'upstream')
|
|
81
|
+
: '';
|
|
82
|
+
|
|
83
|
+
const editCommand = this.buildEditCommand({
|
|
84
|
+
action,
|
|
85
|
+
caddyfilePath,
|
|
86
|
+
domain,
|
|
87
|
+
upstream,
|
|
88
|
+
});
|
|
89
|
+
const editResult = await this.sshService.execute(server, editCommand);
|
|
90
|
+
|
|
91
|
+
if (!editResult.success) {
|
|
92
|
+
if (editResult.exitCode === 4) {
|
|
93
|
+
printJson({
|
|
94
|
+
status: 'error',
|
|
95
|
+
code: 'NOT_FOUND',
|
|
96
|
+
stderr: editResult.stderr || editResult.stdout || 'Route not found',
|
|
97
|
+
command: editCommand,
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
printSshError(editResult, editCommand);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const applyCommand = `cd ${shellQuote(caddyRoot)} && ./caddy.sh`;
|
|
107
|
+
const applyResult = await this.sshService.execute(server, applyCommand);
|
|
108
|
+
if (!applyResult.success) {
|
|
109
|
+
printSshError(applyResult, applyCommand);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
printJson({
|
|
114
|
+
status: 'ok',
|
|
115
|
+
message: 'Routes updated',
|
|
116
|
+
data: {
|
|
117
|
+
action,
|
|
118
|
+
domain,
|
|
119
|
+
upstream: upstream || undefined,
|
|
120
|
+
caddyOutput: applyResult.stdout,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
printCliError(error);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private buildEditCommand(params: {
|
|
130
|
+
action: Exclude<OpsRoutesAction, 'list'>;
|
|
131
|
+
caddyfilePath: string;
|
|
132
|
+
domain: string;
|
|
133
|
+
upstream: string;
|
|
134
|
+
}): string {
|
|
135
|
+
const script = this.buildNodeScript(params);
|
|
136
|
+
return `node -e ${shellQuote(script)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private buildNodeScript(params: {
|
|
140
|
+
action: Exclude<OpsRoutesAction, 'list'>;
|
|
141
|
+
caddyfilePath: string;
|
|
142
|
+
domain: string;
|
|
143
|
+
upstream: string;
|
|
144
|
+
}): string {
|
|
145
|
+
return [
|
|
146
|
+
"const fs = require('node:fs');",
|
|
147
|
+
"const p = require('node:path');",
|
|
148
|
+
`const filePath = ${JSON.stringify(params.caddyfilePath)};`,
|
|
149
|
+
`const action = ${JSON.stringify(params.action)};`,
|
|
150
|
+
`const domain = ${JSON.stringify(params.domain)};`,
|
|
151
|
+
`const upstream = ${JSON.stringify(params.upstream)};`,
|
|
152
|
+
"const esc = (s) => s.replace(/[.*+?^\\$\\{\\}()|[\\]\\\\]/g, '\\\\$&');",
|
|
153
|
+
"const read = fs.readFileSync(filePath, 'utf8');",
|
|
154
|
+
"const domainRe = new RegExp('(^|\\n)\\\\s*' + esc(domain) + '\\s*\\\\{[\\\\s\\\\S]*?\\\\n\\\\}', 'm');",
|
|
155
|
+
'let text = read;',
|
|
156
|
+
"if (action === 'add') {",
|
|
157
|
+
' if (domainRe.test(text)) {',
|
|
158
|
+
" process.stderr.write('Route already exists for domain');",
|
|
159
|
+
' process.exit(3);',
|
|
160
|
+
' }',
|
|
161
|
+
" const block = '\\n' + domain + ' {\\n reverse_proxy ' + upstream + '\\n}\\n';",
|
|
162
|
+
' text = text.replace(/\\s*$/g, "") + block;',
|
|
163
|
+
"} else if (action === 'remove') {",
|
|
164
|
+
' if (!domainRe.test(text)) {',
|
|
165
|
+
" process.stderr.write('Route not found');",
|
|
166
|
+
' process.exit(4);',
|
|
167
|
+
' }',
|
|
168
|
+
" text = text.replace(domainRe, '\\n').replace(/\\n{3,}/g, '\\n\\n').replace(/^\\n+/, '');",
|
|
169
|
+
"} else if (action === 'update') {",
|
|
170
|
+
' const match = text.match(domainRe);',
|
|
171
|
+
' if (!match) {',
|
|
172
|
+
" process.stderr.write('Route not found');",
|
|
173
|
+
' process.exit(4);',
|
|
174
|
+
' }',
|
|
175
|
+
' const block = match[0];',
|
|
176
|
+
" const updated = block.replace(/reverse_proxy\\s+[^\\n]+/, 'reverse_proxy ' + upstream);",
|
|
177
|
+
' text = text.replace(domainRe, updated);',
|
|
178
|
+
'}',
|
|
179
|
+
"const tmp = p.join(p.dirname(filePath), '.tmp-' + Date.now() + '-' + Math.random().toString(36).slice(2));",
|
|
180
|
+
"fs.writeFileSync(tmp, text, 'utf8');",
|
|
181
|
+
'fs.renameSync(tmp, filePath);',
|
|
182
|
+
"process.stdout.write(JSON.stringify({ status: 'ok' }));",
|
|
183
|
+
].join('\n');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { CommandRunner, Option, SubCommand } from 'nest-commander';
|
|
3
|
+
import { ConfigService } from '../../../core/config/config.service';
|
|
4
|
+
import { SshService } from '../../../shared/ssh/ssh.service';
|
|
5
|
+
import type { ResolvedServerConfig } from '../ops.types';
|
|
6
|
+
import {
|
|
7
|
+
ensureAction,
|
|
8
|
+
ensureRequired,
|
|
9
|
+
printCliError,
|
|
10
|
+
printJson,
|
|
11
|
+
printSshError,
|
|
12
|
+
resolveAppsPath,
|
|
13
|
+
resolveServerOrThrow,
|
|
14
|
+
shellQuote,
|
|
15
|
+
} from '../ops.utils';
|
|
16
|
+
|
|
17
|
+
type OpsServiceAction = 'clone' | 'pull' | 'up' | 'down' | 'logs' | 'status';
|
|
18
|
+
|
|
19
|
+
type OpsServiceOptions = {
|
|
20
|
+
repo?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
@SubCommand({
|
|
24
|
+
name: 'service',
|
|
25
|
+
description: 'Manage project lifecycle using Docker Compose',
|
|
26
|
+
arguments: '<alias> <action> <project>',
|
|
27
|
+
})
|
|
28
|
+
export class OpsServiceCommand extends CommandRunner {
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly configService: ConfigService,
|
|
31
|
+
private readonly sshService: SshService,
|
|
32
|
+
) {
|
|
33
|
+
super();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Option({ flags: '--repo <url>', description: 'Repository URL for clone' })
|
|
37
|
+
parseRepo(value: string): string {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async run(
|
|
42
|
+
passedParams: string[],
|
|
43
|
+
options: OpsServiceOptions = {},
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const [alias, rawAction, project] = passedParams;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const action = ensureAction<OpsServiceAction>(
|
|
49
|
+
rawAction,
|
|
50
|
+
['clone', 'pull', 'up', 'down', 'logs', 'status'],
|
|
51
|
+
'service action',
|
|
52
|
+
);
|
|
53
|
+
const server = await resolveServerOrThrow(
|
|
54
|
+
this.configService.getConfig(),
|
|
55
|
+
alias,
|
|
56
|
+
);
|
|
57
|
+
const projectPath = path.posix.join(resolveAppsPath(server), project);
|
|
58
|
+
|
|
59
|
+
if (action === 'status') {
|
|
60
|
+
await this.runStatus(server, projectPath);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const command = this.buildActionCommand(action, projectPath, options);
|
|
65
|
+
const result = await this.sshService.execute(server, command);
|
|
66
|
+
if (!result.success) {
|
|
67
|
+
printSshError(result, command);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
printJson({
|
|
72
|
+
status: 'ok',
|
|
73
|
+
data: {
|
|
74
|
+
action,
|
|
75
|
+
project,
|
|
76
|
+
stdout: result.stdout,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
printCliError(error);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private buildActionCommand(
|
|
86
|
+
action: Exclude<OpsServiceAction, 'status'>,
|
|
87
|
+
projectPath: string,
|
|
88
|
+
options: OpsServiceOptions,
|
|
89
|
+
): string {
|
|
90
|
+
const quotedProjectPath = shellQuote(projectPath);
|
|
91
|
+
|
|
92
|
+
if (action === 'clone') {
|
|
93
|
+
const repo = ensureRequired(options.repo, 'repo');
|
|
94
|
+
const quotedRepo = shellQuote(repo);
|
|
95
|
+
return [
|
|
96
|
+
`if [ -d ${quotedProjectPath} ]; then echo 'Project already exists' >&2; exit 3; fi`,
|
|
97
|
+
`git clone ${quotedRepo} ${quotedProjectPath}`,
|
|
98
|
+
].join(' && ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (action === 'pull') {
|
|
102
|
+
return `cd ${quotedProjectPath} && git pull`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (action === 'up') {
|
|
106
|
+
return `cd ${quotedProjectPath} && docker compose up -d`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (action === 'down') {
|
|
110
|
+
return `cd ${quotedProjectPath} && docker compose down`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return `cd ${quotedProjectPath} && docker compose logs --no-color --tail=200`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async runStatus(
|
|
117
|
+
server: ResolvedServerConfig,
|
|
118
|
+
projectPath: string,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const quotedProjectPath = shellQuote(projectPath);
|
|
121
|
+
const jsonCommand = `cd ${quotedProjectPath} && docker compose ps --format json`;
|
|
122
|
+
const jsonResult = await this.sshService.execute(server, jsonCommand);
|
|
123
|
+
|
|
124
|
+
if (jsonResult.success) {
|
|
125
|
+
printJson({
|
|
126
|
+
status: 'ok',
|
|
127
|
+
data: {
|
|
128
|
+
action: 'status',
|
|
129
|
+
stdout: jsonResult.stdout,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const fallbackCommand = `cd ${quotedProjectPath} && docker compose ps`;
|
|
136
|
+
const fallbackResult = await this.sshService.execute(
|
|
137
|
+
server,
|
|
138
|
+
fallbackCommand,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (!fallbackResult.success) {
|
|
142
|
+
printSshError(fallbackResult, fallbackCommand);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
printJson({
|
|
147
|
+
status: 'ok',
|
|
148
|
+
data: {
|
|
149
|
+
action: 'status',
|
|
150
|
+
raw: fallbackResult.stdout,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|