@yibi/newapi-sub2api-stack 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 +110 -0
- package/bin/api-stack.js +284 -0
- package/lib/config.js +230 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# New API + Sub2API Stack Installer
|
|
2
|
+
|
|
3
|
+
An npm CLI that installs the upstream New API and Sub2API Docker stacks on a
|
|
4
|
+
Linux host. It generates credentials locally, creates two isolated Compose
|
|
5
|
+
networks, pulls upstream images, starts the services, and verifies their HTTP
|
|
6
|
+
health endpoints.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Linux with Node.js 20 or newer and `npx`
|
|
11
|
+
- Docker Engine with a running daemon
|
|
12
|
+
- Docker Compose plugin (`docker compose`)
|
|
13
|
+
- The current user must be able to run `docker info` without `sudo`
|
|
14
|
+
- Network access to the npm registry and Docker Hub
|
|
15
|
+
|
|
16
|
+
Node.js is only needed to run this installer. The applications themselves run
|
|
17
|
+
entirely in Docker.
|
|
18
|
+
|
|
19
|
+
## Run Locally
|
|
20
|
+
|
|
21
|
+
From this package directory:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx --yes . install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Until the package is published, it can also be run by absolute directory:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx --yes /home/ubuntu/newapi-sub2api-installer install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
After publication to npm:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx --yes @yibi/newapi-sub2api-stack@0.1.0 install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Production automation should specify an exact npm package version instead of
|
|
40
|
+
`@latest`.
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
api-stack install
|
|
46
|
+
api-stack status
|
|
47
|
+
api-stack logs
|
|
48
|
+
api-stack logs --service new-api
|
|
49
|
+
api-stack update
|
|
50
|
+
api-stack uninstall
|
|
51
|
+
api-stack uninstall --purge --confirm-purge
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The default installation directory is `~/.local/share/api-stack`. Override it
|
|
55
|
+
for every command with `--install-dir`, or set `API_STACK_INSTALL_DIR`.
|
|
56
|
+
|
|
57
|
+
Example unattended installation:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx --yes @yibi/newapi-sub2api-stack@0.1.0 install \
|
|
61
|
+
--new-api-host 0.0.0.0 \
|
|
62
|
+
--new-api-port 3000 \
|
|
63
|
+
--sub2api-host 127.0.0.1 \
|
|
64
|
+
--sub2api-port 8081 \
|
|
65
|
+
--timezone Asia/Shanghai
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Installed Services
|
|
69
|
+
|
|
70
|
+
The generated Compose project contains two isolated networks:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
new-api-network sub2api-network
|
|
74
|
+
new-api sub2api
|
|
75
|
+
new-api-postgres sub2api-postgres
|
|
76
|
+
new-api-redis sub2api-redis
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Defaults follow the upstream deployment layouts:
|
|
80
|
+
|
|
81
|
+
- `calciumion/new-api:latest`, PostgreSQL 15, and Redis
|
|
82
|
+
- `weishaw/sub2api:latest`, PostgreSQL 18 Alpine, and Redis 8 Alpine
|
|
83
|
+
- New API listens on `0.0.0.0:3000`
|
|
84
|
+
- Sub2API listens on `127.0.0.1:8081`
|
|
85
|
+
- Databases and Redis are not exposed on host ports
|
|
86
|
+
|
|
87
|
+
Use `--new-api-image` and `--sub2api-image` to pin application tags or digests.
|
|
88
|
+
|
|
89
|
+
## Credentials and Data
|
|
90
|
+
|
|
91
|
+
Credentials are generated with Node's cryptographic random generator and
|
|
92
|
+
stored in `.env` with mode `0600`. They are never embedded in
|
|
93
|
+
`docker-compose.yml` or printed by the installer. The generated Sub2API admin
|
|
94
|
+
password is stored as `SUB2API_ADMIN_PASSWORD` in that file. New API completes
|
|
95
|
+
its initial administrator setup in the web interface.
|
|
96
|
+
|
|
97
|
+
`uninstall` preserves all volumes and configuration. Permanent removal requires
|
|
98
|
+
both `--purge` and `--confirm-purge`.
|
|
99
|
+
|
|
100
|
+
## Publishing
|
|
101
|
+
|
|
102
|
+
Verify the package before publishing:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm test
|
|
106
|
+
npm pack --dry-run
|
|
107
|
+
npm publish
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The package is published publicly under the `@yibi` npm organization scope.
|
package/bin/api-stack.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { createSecrets, DEFAULTS, renderCompose, renderEnv } from '../lib/config.js';
|
|
9
|
+
|
|
10
|
+
const PROJECT_NAME = 'api-stack';
|
|
11
|
+
const STATE_FILE = 'state.json';
|
|
12
|
+
|
|
13
|
+
function usage() {
|
|
14
|
+
console.log(`Usage: api-stack <command> [options]
|
|
15
|
+
|
|
16
|
+
Commands:
|
|
17
|
+
install Create configuration, pull images, and start both stacks
|
|
18
|
+
status Show container and HTTP health status
|
|
19
|
+
logs Follow logs (use --service <name> to select one service)
|
|
20
|
+
update Pull current configured images and recreate containers
|
|
21
|
+
uninstall Stop containers while preserving data and configuration
|
|
22
|
+
help Show this help
|
|
23
|
+
|
|
24
|
+
Install options:
|
|
25
|
+
--install-dir <path> Default: ${DEFAULTS.installDir}
|
|
26
|
+
--new-api-port <port> Default: ${DEFAULTS.newApiPort}
|
|
27
|
+
--new-api-host <address> Default: ${DEFAULTS.newApiHost}
|
|
28
|
+
--sub2api-port <port> Default: ${DEFAULTS.sub2apiPort}
|
|
29
|
+
--sub2api-host <address> Default: ${DEFAULTS.sub2apiHost}
|
|
30
|
+
--timezone <timezone> Default: ${DEFAULTS.timezone}
|
|
31
|
+
--new-api-image <image> Default: ${DEFAULTS.newApiImage}
|
|
32
|
+
--sub2api-image <image> Default: ${DEFAULTS.sub2apiImage}
|
|
33
|
+
--sub2api-admin-email <email> Default: ${DEFAULTS.sub2apiAdminEmail}
|
|
34
|
+
|
|
35
|
+
Uninstall options:
|
|
36
|
+
--purge --confirm-purge Also delete volumes and local configuration
|
|
37
|
+
`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
if (argv.length === 0 || ['help', '--help', '-h'].includes(argv[0])) {
|
|
42
|
+
return { command: 'help', flags: {} };
|
|
43
|
+
}
|
|
44
|
+
const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'help';
|
|
45
|
+
const rest = argv.slice(1);
|
|
46
|
+
const flags = {};
|
|
47
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
48
|
+
const arg = rest[index];
|
|
49
|
+
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
|
|
50
|
+
const key = arg.slice(2);
|
|
51
|
+
if (['purge', 'confirm-purge'].includes(key)) {
|
|
52
|
+
flags[key] = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const value = rest[index + 1];
|
|
56
|
+
if (!value || value.startsWith('--')) throw new Error(`missing value for ${arg}`);
|
|
57
|
+
flags[key] = value;
|
|
58
|
+
index += 1;
|
|
59
|
+
}
|
|
60
|
+
return { command, flags };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function expandHome(path) {
|
|
64
|
+
if (path === '~') return homedir();
|
|
65
|
+
if (path.startsWith('~/')) return join(homedir(), path.slice(2));
|
|
66
|
+
return resolve(path);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function installDir(flags) {
|
|
70
|
+
return expandHome(flags['install-dir'] || process.env.API_STACK_INSTALL_DIR || DEFAULTS.installDir);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parsePort(value, name) {
|
|
74
|
+
const port = Number(value);
|
|
75
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
76
|
+
throw new Error(`${name} must be an integer between 1 and 65535`);
|
|
77
|
+
}
|
|
78
|
+
return port;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function installOptions(flags) {
|
|
82
|
+
return {
|
|
83
|
+
timezone: flags.timezone || DEFAULTS.timezone,
|
|
84
|
+
newApiImage: flags['new-api-image'] || DEFAULTS.newApiImage,
|
|
85
|
+
sub2apiImage: flags['sub2api-image'] || DEFAULTS.sub2apiImage,
|
|
86
|
+
newApiHost: flags['new-api-host'] || DEFAULTS.newApiHost,
|
|
87
|
+
newApiPort: parsePort(flags['new-api-port'] || DEFAULTS.newApiPort, '--new-api-port'),
|
|
88
|
+
sub2apiHost: flags['sub2api-host'] || DEFAULTS.sub2apiHost,
|
|
89
|
+
sub2apiPort: parsePort(flags['sub2api-port'] || DEFAULTS.sub2apiPort, '--sub2api-port'),
|
|
90
|
+
sub2apiAdminEmail: flags['sub2api-admin-email'] || DEFAULTS.sub2apiAdminEmail,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function run(command, args, options = {}) {
|
|
95
|
+
const result = spawnSync(command, args, {
|
|
96
|
+
cwd: options.cwd,
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
stdio: options.capture ? 'pipe' : 'inherit',
|
|
99
|
+
});
|
|
100
|
+
if (result.error) throw result.error;
|
|
101
|
+
if (result.status !== 0 && !options.allowFailure) {
|
|
102
|
+
const detail = options.capture ? `\n${result.stderr || result.stdout}` : '';
|
|
103
|
+
throw new Error(`${command} exited with status ${result.status}${detail}`);
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function requireDocker() {
|
|
109
|
+
if (process.platform !== 'linux') throw new Error('this installer currently supports Linux only');
|
|
110
|
+
if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('Node.js 20 or newer is required');
|
|
111
|
+
run('docker', ['info'], { capture: true });
|
|
112
|
+
run('docker', ['compose', 'version'], { capture: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function composeArgs(dir, args) {
|
|
116
|
+
return ['compose', '--project-name', PROJECT_NAME, '--env-file', join(dir, '.env'), '-f', join(dir, 'docker-compose.yml'), ...args];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function compose(dir, args, options = {}) {
|
|
120
|
+
return run('docker', composeArgs(dir, args), { cwd: dir, ...options });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeSecure(path, content, mode) {
|
|
124
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
125
|
+
writeFileSync(path, content, { encoding: 'utf8', flag: 'wx', mode });
|
|
126
|
+
chmodSync(path, mode);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readState(dir) {
|
|
130
|
+
const path = join(dir, STATE_FILE);
|
|
131
|
+
if (!existsSync(path)) return null;
|
|
132
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeState(dir, state) {
|
|
136
|
+
const path = join(dir, STATE_FILE);
|
|
137
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
138
|
+
chmodSync(path, 0o600);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function waitForHttp(name, url, predicate, attempts = 60) {
|
|
142
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
145
|
+
if (response.ok && await predicate(response)) {
|
|
146
|
+
console.log(`${name}: healthy (${url})`);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// The container may still be starting.
|
|
151
|
+
}
|
|
152
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000));
|
|
153
|
+
}
|
|
154
|
+
console.error(`${name}: health check timed out (${url})`);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function health(options) {
|
|
159
|
+
const newApiHealthy = await waitForHttp(
|
|
160
|
+
'new-api',
|
|
161
|
+
`http://127.0.0.1:${options.newApiPort}/api/status`,
|
|
162
|
+
async (response) => (await response.json()).success === true,
|
|
163
|
+
);
|
|
164
|
+
const sub2apiHealthy = await waitForHttp(
|
|
165
|
+
'sub2api',
|
|
166
|
+
`http://127.0.0.1:${options.sub2apiPort}/health`,
|
|
167
|
+
async (response) => (await response.json()).status === 'ok',
|
|
168
|
+
);
|
|
169
|
+
return newApiHealthy && sub2apiHealthy;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function install(flags) {
|
|
173
|
+
requireDocker();
|
|
174
|
+
const dir = installDir(flags);
|
|
175
|
+
const existing = readState(dir);
|
|
176
|
+
let state;
|
|
177
|
+
|
|
178
|
+
if (existing?.installed) {
|
|
179
|
+
throw new Error(`already installed in ${dir}; use update or status`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (existing) {
|
|
183
|
+
for (const file of ['.env', 'docker-compose.yml']) {
|
|
184
|
+
if (!existsSync(join(dir, file))) throw new Error(`incomplete installation: missing ${join(dir, file)}`);
|
|
185
|
+
}
|
|
186
|
+
state = existing;
|
|
187
|
+
console.log(`resuming incomplete installation in ${dir}`);
|
|
188
|
+
} else {
|
|
189
|
+
const options = installOptions(flags);
|
|
190
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
191
|
+
chmodSync(dir, 0o700);
|
|
192
|
+
mkdirSync(join(dir, 'data', 'new-api'), { recursive: true, mode: 0o700 });
|
|
193
|
+
mkdirSync(join(dir, 'data', 'new-api-logs'), { recursive: true, mode: 0o700 });
|
|
194
|
+
writeSecure(join(dir, '.env'), renderEnv(createSecrets()), 0o600);
|
|
195
|
+
writeSecure(join(dir, 'docker-compose.yml'), renderCompose(options), 0o600);
|
|
196
|
+
state = { version: 1, installed: false, createdAt: new Date().toISOString(), options };
|
|
197
|
+
writeState(dir, state);
|
|
198
|
+
console.log(`configuration created in ${dir}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
compose(dir, ['config', '--quiet']);
|
|
202
|
+
compose(dir, ['pull']);
|
|
203
|
+
compose(dir, ['up', '-d', '--remove-orphans']);
|
|
204
|
+
if (!await health(state.options)) throw new Error('containers started, but one or more health checks failed');
|
|
205
|
+
|
|
206
|
+
state.installed = true;
|
|
207
|
+
state.installedAt = new Date().toISOString();
|
|
208
|
+
writeState(dir, state);
|
|
209
|
+
console.log(`\nInstallation complete.
|
|
210
|
+
New API: http://${state.options.newApiHost === '0.0.0.0' ? '127.0.0.1' : state.options.newApiHost}:${state.options.newApiPort}
|
|
211
|
+
Sub2API: http://${state.options.sub2apiHost === '0.0.0.0' ? '127.0.0.1' : state.options.sub2apiHost}:${state.options.sub2apiPort}
|
|
212
|
+
Sub2API credentials are stored in ${join(dir, '.env')}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function requireInstallation(flags) {
|
|
216
|
+
const dir = installDir(flags);
|
|
217
|
+
const state = readState(dir);
|
|
218
|
+
if (!state) throw new Error(`no installation found in ${dir}`);
|
|
219
|
+
return { dir, state };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function status(flags) {
|
|
223
|
+
requireDocker();
|
|
224
|
+
const { dir, state } = requireInstallation(flags);
|
|
225
|
+
compose(dir, ['ps']);
|
|
226
|
+
const ok = await health(state.options);
|
|
227
|
+
if (!ok) process.exitCode = 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function logs(flags) {
|
|
231
|
+
requireDocker();
|
|
232
|
+
const { dir } = requireInstallation(flags);
|
|
233
|
+
const args = ['logs', '--follow', '--tail', '200'];
|
|
234
|
+
if (flags.service) args.push(flags.service);
|
|
235
|
+
compose(dir, args);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function update(flags) {
|
|
239
|
+
requireDocker();
|
|
240
|
+
const { dir, state } = requireInstallation(flags);
|
|
241
|
+
compose(dir, ['pull']);
|
|
242
|
+
compose(dir, ['up', '-d', '--remove-orphans']);
|
|
243
|
+
if (!await health(state.options)) throw new Error('update completed, but one or more health checks failed');
|
|
244
|
+
state.updatedAt = new Date().toISOString();
|
|
245
|
+
writeState(dir, state);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function uninstall(flags) {
|
|
249
|
+
requireDocker();
|
|
250
|
+
const { dir } = requireInstallation(flags);
|
|
251
|
+
if (flags.purge && !flags['confirm-purge']) {
|
|
252
|
+
throw new Error('--purge permanently deletes databases; repeat with --purge --confirm-purge');
|
|
253
|
+
}
|
|
254
|
+
if (flags.purge) {
|
|
255
|
+
const normalized = resolve(dir);
|
|
256
|
+
const depth = normalized.split('/').filter(Boolean).length;
|
|
257
|
+
if (normalized === resolve(homedir()) || depth < 2) {
|
|
258
|
+
throw new Error(`refusing to purge unsafe installation directory: ${normalized}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
compose(dir, flags.purge ? ['down', '--volumes', '--remove-orphans'] : ['down', '--remove-orphans']);
|
|
262
|
+
if (flags.purge) {
|
|
263
|
+
rmSync(dir, { recursive: true, force: true });
|
|
264
|
+
console.log(`deleted containers, volumes, and ${dir}`);
|
|
265
|
+
} else {
|
|
266
|
+
console.log(`containers stopped; configuration and data were preserved in ${dir}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function main() {
|
|
271
|
+
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
272
|
+
if (command === 'help' || command === '--help' || command === '-h') return usage();
|
|
273
|
+
if (command === 'install') return install(flags);
|
|
274
|
+
if (command === 'status') return status(flags);
|
|
275
|
+
if (command === 'logs') return logs(flags);
|
|
276
|
+
if (command === 'update') return update(flags);
|
|
277
|
+
if (command === 'uninstall') return uninstall(flags);
|
|
278
|
+
throw new Error(`unknown command: ${command}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
main().catch((error) => {
|
|
282
|
+
console.error(`error: ${error.message}`);
|
|
283
|
+
process.exitCode = 1;
|
|
284
|
+
});
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const DEFAULTS = Object.freeze({
|
|
4
|
+
installDir: '~/.local/share/api-stack',
|
|
5
|
+
timezone: 'Asia/Shanghai',
|
|
6
|
+
newApiImage: 'calciumion/new-api:latest',
|
|
7
|
+
sub2apiImage: 'weishaw/sub2api:latest',
|
|
8
|
+
newApiHost: '0.0.0.0',
|
|
9
|
+
newApiPort: 3000,
|
|
10
|
+
sub2apiHost: '127.0.0.1',
|
|
11
|
+
sub2apiPort: 8081,
|
|
12
|
+
sub2apiAdminEmail: 'admin@sub2api.local',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const SECRET_KEYS = Object.freeze([
|
|
16
|
+
'NEW_API_POSTGRES_PASSWORD',
|
|
17
|
+
'NEW_API_REDIS_PASSWORD',
|
|
18
|
+
'NEW_API_SESSION_SECRET',
|
|
19
|
+
'NEW_API_CRYPTO_SECRET',
|
|
20
|
+
'SUB2API_POSTGRES_PASSWORD',
|
|
21
|
+
'SUB2API_REDIS_PASSWORD',
|
|
22
|
+
'SUB2API_JWT_SECRET',
|
|
23
|
+
'SUB2API_TOTP_ENCRYPTION_KEY',
|
|
24
|
+
'SUB2API_ADMIN_PASSWORD',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function secret(bytes = 32) {
|
|
28
|
+
return randomBytes(bytes).toString('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createSecrets() {
|
|
32
|
+
return {
|
|
33
|
+
NEW_API_POSTGRES_PASSWORD: secret(),
|
|
34
|
+
NEW_API_REDIS_PASSWORD: secret(),
|
|
35
|
+
NEW_API_SESSION_SECRET: secret(),
|
|
36
|
+
NEW_API_CRYPTO_SECRET: secret(),
|
|
37
|
+
SUB2API_POSTGRES_PASSWORD: secret(),
|
|
38
|
+
SUB2API_REDIS_PASSWORD: secret(),
|
|
39
|
+
SUB2API_JWT_SECRET: secret(),
|
|
40
|
+
SUB2API_TOTP_ENCRYPTION_KEY: secret(),
|
|
41
|
+
SUB2API_ADMIN_PASSWORD: randomBytes(24).toString('base64url'),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderEnv(secrets) {
|
|
46
|
+
for (const key of SECRET_KEYS) {
|
|
47
|
+
if (!secrets[key] || !/^[A-Za-z0-9_-]+$/.test(secrets[key])) {
|
|
48
|
+
throw new Error(`invalid generated secret: ${key}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return `${SECRET_KEYS.map((key) => `${key}=${secrets[key]}`).join('\n')}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function yamlString(value) {
|
|
55
|
+
return JSON.stringify(String(value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderCompose(options) {
|
|
59
|
+
const o = { ...DEFAULTS, ...options };
|
|
60
|
+
return `# Generated by newapi-sub2api-stack. Do not put secrets in this file.
|
|
61
|
+
services:
|
|
62
|
+
new-api:
|
|
63
|
+
image: ${yamlString(o.newApiImage)}
|
|
64
|
+
container_name: new-api
|
|
65
|
+
restart: always
|
|
66
|
+
command: ["--log-dir", "/app/logs"]
|
|
67
|
+
ports:
|
|
68
|
+
- ${yamlString(`${o.newApiHost}:${o.newApiPort}:3000`)}
|
|
69
|
+
volumes:
|
|
70
|
+
- ./data/new-api:/data
|
|
71
|
+
- ./data/new-api-logs:/app/logs
|
|
72
|
+
environment:
|
|
73
|
+
SQL_DSN: "postgresql://newapi:\${NEW_API_POSTGRES_PASSWORD:?missing NEW_API_POSTGRES_PASSWORD}@new-api-postgres:5432/new-api"
|
|
74
|
+
REDIS_CONN_STRING: "redis://:\${NEW_API_REDIS_PASSWORD:?missing NEW_API_REDIS_PASSWORD}@new-api-redis:6379"
|
|
75
|
+
SESSION_SECRET: "\${NEW_API_SESSION_SECRET:?missing NEW_API_SESSION_SECRET}"
|
|
76
|
+
CRYPTO_SECRET: "\${NEW_API_CRYPTO_SECRET:?missing NEW_API_CRYPTO_SECRET}"
|
|
77
|
+
TZ: ${yamlString(o.timezone)}
|
|
78
|
+
ERROR_LOG_ENABLED: "true"
|
|
79
|
+
BATCH_UPDATE_ENABLED: "true"
|
|
80
|
+
NODE_NAME: "new-api-node-1"
|
|
81
|
+
depends_on:
|
|
82
|
+
new-api-postgres:
|
|
83
|
+
condition: service_healthy
|
|
84
|
+
new-api-redis:
|
|
85
|
+
condition: service_healthy
|
|
86
|
+
networks:
|
|
87
|
+
- new-api-network
|
|
88
|
+
healthcheck:
|
|
89
|
+
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -q '\\"success\\":true'"]
|
|
90
|
+
interval: 30s
|
|
91
|
+
timeout: 10s
|
|
92
|
+
retries: 5
|
|
93
|
+
start_period: 30s
|
|
94
|
+
|
|
95
|
+
new-api-postgres:
|
|
96
|
+
image: postgres:15
|
|
97
|
+
container_name: new-api-postgres
|
|
98
|
+
restart: always
|
|
99
|
+
environment:
|
|
100
|
+
POSTGRES_USER: newapi
|
|
101
|
+
POSTGRES_PASSWORD: "\${NEW_API_POSTGRES_PASSWORD:?missing NEW_API_POSTGRES_PASSWORD}"
|
|
102
|
+
POSTGRES_DB: new-api
|
|
103
|
+
TZ: ${yamlString(o.timezone)}
|
|
104
|
+
volumes:
|
|
105
|
+
- new_api_postgres_data:/var/lib/postgresql/data
|
|
106
|
+
networks:
|
|
107
|
+
- new-api-network
|
|
108
|
+
healthcheck:
|
|
109
|
+
test: ["CMD-SHELL", "pg_isready -U newapi -d new-api"]
|
|
110
|
+
interval: 10s
|
|
111
|
+
timeout: 5s
|
|
112
|
+
retries: 10
|
|
113
|
+
|
|
114
|
+
new-api-redis:
|
|
115
|
+
image: redis:latest
|
|
116
|
+
container_name: new-api-redis
|
|
117
|
+
restart: always
|
|
118
|
+
command: ["redis-server", "--appendonly", "yes", "--requirepass", "\${NEW_API_REDIS_PASSWORD:?missing NEW_API_REDIS_PASSWORD}"]
|
|
119
|
+
environment:
|
|
120
|
+
REDISCLI_AUTH: "\${NEW_API_REDIS_PASSWORD:?missing NEW_API_REDIS_PASSWORD}"
|
|
121
|
+
TZ: ${yamlString(o.timezone)}
|
|
122
|
+
volumes:
|
|
123
|
+
- new_api_redis_data:/data
|
|
124
|
+
networks:
|
|
125
|
+
- new-api-network
|
|
126
|
+
healthcheck:
|
|
127
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
128
|
+
interval: 10s
|
|
129
|
+
timeout: 5s
|
|
130
|
+
retries: 10
|
|
131
|
+
|
|
132
|
+
sub2api:
|
|
133
|
+
image: ${yamlString(o.sub2apiImage)}
|
|
134
|
+
container_name: sub2api
|
|
135
|
+
restart: unless-stopped
|
|
136
|
+
ulimits:
|
|
137
|
+
nofile:
|
|
138
|
+
soft: 100000
|
|
139
|
+
hard: 100000
|
|
140
|
+
ports:
|
|
141
|
+
- ${yamlString(`${o.sub2apiHost}:${o.sub2apiPort}:8080`)}
|
|
142
|
+
volumes:
|
|
143
|
+
- sub2api_data:/app/data
|
|
144
|
+
environment:
|
|
145
|
+
AUTO_SETUP: "true"
|
|
146
|
+
SERVER_HOST: "0.0.0.0"
|
|
147
|
+
SERVER_PORT: "8080"
|
|
148
|
+
SERVER_MODE: "release"
|
|
149
|
+
RUN_MODE: "standard"
|
|
150
|
+
DATABASE_HOST: "sub2api-postgres"
|
|
151
|
+
DATABASE_PORT: "5432"
|
|
152
|
+
DATABASE_USER: "sub2api"
|
|
153
|
+
DATABASE_PASSWORD: "\${SUB2API_POSTGRES_PASSWORD:?missing SUB2API_POSTGRES_PASSWORD}"
|
|
154
|
+
DATABASE_DBNAME: "sub2api"
|
|
155
|
+
DATABASE_SSLMODE: "disable"
|
|
156
|
+
REDIS_HOST: "sub2api-redis"
|
|
157
|
+
REDIS_PORT: "6379"
|
|
158
|
+
REDIS_PASSWORD: "\${SUB2API_REDIS_PASSWORD:?missing SUB2API_REDIS_PASSWORD}"
|
|
159
|
+
REDIS_DB: "0"
|
|
160
|
+
ADMIN_EMAIL: ${yamlString(o.sub2apiAdminEmail)}
|
|
161
|
+
ADMIN_PASSWORD: "\${SUB2API_ADMIN_PASSWORD:?missing SUB2API_ADMIN_PASSWORD}"
|
|
162
|
+
JWT_SECRET: "\${SUB2API_JWT_SECRET:?missing SUB2API_JWT_SECRET}"
|
|
163
|
+
TOTP_ENCRYPTION_KEY: "\${SUB2API_TOTP_ENCRYPTION_KEY:?missing SUB2API_TOTP_ENCRYPTION_KEY}"
|
|
164
|
+
TZ: ${yamlString(o.timezone)}
|
|
165
|
+
depends_on:
|
|
166
|
+
sub2api-postgres:
|
|
167
|
+
condition: service_healthy
|
|
168
|
+
sub2api-redis:
|
|
169
|
+
condition: service_healthy
|
|
170
|
+
networks:
|
|
171
|
+
- sub2api-network
|
|
172
|
+
healthcheck:
|
|
173
|
+
test: ["CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8080/health"]
|
|
174
|
+
interval: 30s
|
|
175
|
+
timeout: 10s
|
|
176
|
+
retries: 5
|
|
177
|
+
start_period: 30s
|
|
178
|
+
|
|
179
|
+
sub2api-postgres:
|
|
180
|
+
image: postgres:18-alpine
|
|
181
|
+
container_name: sub2api-postgres
|
|
182
|
+
restart: unless-stopped
|
|
183
|
+
environment:
|
|
184
|
+
PGDATA: "/var/lib/postgresql/data"
|
|
185
|
+
POSTGRES_USER: "sub2api"
|
|
186
|
+
POSTGRES_PASSWORD: "\${SUB2API_POSTGRES_PASSWORD:?missing SUB2API_POSTGRES_PASSWORD}"
|
|
187
|
+
POSTGRES_DB: "sub2api"
|
|
188
|
+
TZ: ${yamlString(o.timezone)}
|
|
189
|
+
volumes:
|
|
190
|
+
- sub2api_postgres_data:/var/lib/postgresql/data
|
|
191
|
+
networks:
|
|
192
|
+
- sub2api-network
|
|
193
|
+
healthcheck:
|
|
194
|
+
test: ["CMD-SHELL", "pg_isready -U sub2api -d sub2api"]
|
|
195
|
+
interval: 10s
|
|
196
|
+
timeout: 5s
|
|
197
|
+
retries: 10
|
|
198
|
+
|
|
199
|
+
sub2api-redis:
|
|
200
|
+
image: redis:8-alpine
|
|
201
|
+
container_name: sub2api-redis
|
|
202
|
+
restart: unless-stopped
|
|
203
|
+
command: ["redis-server", "--save", "60", "1", "--appendonly", "yes", "--appendfsync", "everysec", "--requirepass", "\${SUB2API_REDIS_PASSWORD:?missing SUB2API_REDIS_PASSWORD}"]
|
|
204
|
+
environment:
|
|
205
|
+
REDISCLI_AUTH: "\${SUB2API_REDIS_PASSWORD:?missing SUB2API_REDIS_PASSWORD}"
|
|
206
|
+
TZ: ${yamlString(o.timezone)}
|
|
207
|
+
volumes:
|
|
208
|
+
- sub2api_redis_data:/data
|
|
209
|
+
networks:
|
|
210
|
+
- sub2api-network
|
|
211
|
+
healthcheck:
|
|
212
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
213
|
+
interval: 10s
|
|
214
|
+
timeout: 5s
|
|
215
|
+
retries: 10
|
|
216
|
+
|
|
217
|
+
volumes:
|
|
218
|
+
new_api_postgres_data:
|
|
219
|
+
new_api_redis_data:
|
|
220
|
+
sub2api_data:
|
|
221
|
+
sub2api_postgres_data:
|
|
222
|
+
sub2api_redis_data:
|
|
223
|
+
|
|
224
|
+
networks:
|
|
225
|
+
new-api-network:
|
|
226
|
+
driver: bridge
|
|
227
|
+
sub2api-network:
|
|
228
|
+
driver: bridge
|
|
229
|
+
`;
|
|
230
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yibi/newapi-sub2api-stack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One-command Docker Compose installer for upstream New API and Sub2API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"api-stack": "bin/api-stack.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test",
|
|
16
|
+
"check": "node --check bin/api-stack.js && node --check lib/config.js"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT"
|
|
22
|
+
}
|