@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 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.
@@ -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
+ }