@kne/fastify-app-manager 0.1.1
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 +486 -0
- package/index.js +87 -0
- package/libs/controllers/app.js +274 -0
- package/libs/controllers/gateway.js +88 -0
- package/libs/models/app.js +88 -0
- package/libs/models/appVersion.js +43 -0
- package/libs/services/app.js +470 -0
- package/libs/services/bootstrap.js +102 -0
- package/libs/utils/entryInject.js +76 -0
- package/libs/utils/env.js +79 -0
- package/libs/utils/logFiles.js +52 -0
- package/libs/utils/logStream.js +39 -0
- package/libs/utils/migrate.js +70 -0
- package/libs/utils/pathRewrite.js +55 -0
- package/libs/utils/pm2.js +96 -0
- package/libs/utils/portPool.js +29 -0
- package/libs/utils/validatePackage.js +59 -0
- package/libs/utils/version.js +77 -0
- package/libs/utils/zip.js +69 -0
- package/package.json +78 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const SECRET_MASK = '********';
|
|
2
|
+
|
|
3
|
+
const pickHostEnv = (passthroughEnvKeys = []) => {
|
|
4
|
+
const result = {};
|
|
5
|
+
for (const key of passthroughEnvKeys) {
|
|
6
|
+
if (Object.prototype.hasOwnProperty.call(process.env, key) && process.env[key] != null) {
|
|
7
|
+
result[key] = process.env[key];
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return result;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const mergeEnv = ({ passthroughEnvKeys = [], appEnv = {}, port }) => {
|
|
14
|
+
const merged = Object.assign({}, pickHostEnv(passthroughEnvKeys), appEnv || {}, {
|
|
15
|
+
PORT: String(port)
|
|
16
|
+
});
|
|
17
|
+
return merged;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const isSecretKey = (key, pattern) => {
|
|
21
|
+
const re = pattern instanceof RegExp ? pattern : new RegExp(pattern || '(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)', 'i');
|
|
22
|
+
return re.test(key);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern } = {}) => {
|
|
26
|
+
const masked = {};
|
|
27
|
+
for (const [key, value] of Object.entries(appEnv || {})) {
|
|
28
|
+
if (isSecretKey(key, secretEnvKeyPattern)) {
|
|
29
|
+
masked[key] = value == null || value === '' ? null : SECRET_MASK;
|
|
30
|
+
} else {
|
|
31
|
+
masked[key] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return masked;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* merge patch into current env.
|
|
39
|
+
* null => delete key
|
|
40
|
+
* SECRET_MASK => keep existing
|
|
41
|
+
* other => set
|
|
42
|
+
*/
|
|
43
|
+
const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern } = {}) => {
|
|
44
|
+
const next = Object.assign({}, current);
|
|
45
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
46
|
+
if (value === null) {
|
|
47
|
+
delete next[key];
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (value === SECRET_MASK && isSecretKey(key, secretEnvKeyPattern)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
next[key] = value;
|
|
54
|
+
}
|
|
55
|
+
return next;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const PM2_CONFIG_KEYS = ['exec_mode', 'instances', 'autorestart', 'max_memory_restart', 'max_restarts', 'min_uptime', 'kill_timeout', 'merge_logs'];
|
|
59
|
+
|
|
60
|
+
const mergePm2Config = (defaults = {}, appConfig = {}) => {
|
|
61
|
+
const merged = Object.assign({}, defaults);
|
|
62
|
+
for (const key of PM2_CONFIG_KEYS) {
|
|
63
|
+
if (appConfig && Object.prototype.hasOwnProperty.call(appConfig, key) && appConfig[key] != null) {
|
|
64
|
+
merged[key] = appConfig[key];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
SECRET_MASK,
|
|
72
|
+
pickHostEnv,
|
|
73
|
+
mergeEnv,
|
|
74
|
+
isSecretKey,
|
|
75
|
+
maskEnvForResponse,
|
|
76
|
+
applyEnvPatch,
|
|
77
|
+
PM2_CONFIG_KEYS,
|
|
78
|
+
mergePm2Config
|
|
79
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const { EventEmitter } = require('node:events');
|
|
4
|
+
|
|
5
|
+
const createLogHub = () => {
|
|
6
|
+
const emitter = new EventEmitter();
|
|
7
|
+
emitter.setMaxListeners(0);
|
|
8
|
+
return emitter;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const ensureLogFiles = async appRoot => {
|
|
12
|
+
const logsDir = path.join(appRoot, 'logs');
|
|
13
|
+
await fs.ensureDir(logsDir);
|
|
14
|
+
const outFile = path.join(logsDir, 'out.log');
|
|
15
|
+
const errFile = path.join(logsDir, 'err.log');
|
|
16
|
+
await fs.ensureFile(outFile);
|
|
17
|
+
await fs.ensureFile(errFile);
|
|
18
|
+
return { logsDir, outFile, errFile };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const appendLog = async (filePath, content) => {
|
|
22
|
+
await fs.appendFile(filePath, content.endsWith('\n') ? content : `${content}\n`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const readLogTail = async (filePath, { perPage = 100, currentPage = 1 } = {}) => {
|
|
26
|
+
if (!(await fs.pathExists(filePath))) {
|
|
27
|
+
return { pageData: [], totalCount: 0 };
|
|
28
|
+
}
|
|
29
|
+
const text = await fs.readFile(filePath, 'utf8');
|
|
30
|
+
const lines = text.split(/\r?\n/).filter((line, idx, arr) => !(idx === arr.length - 1 && line === ''));
|
|
31
|
+
const totalCount = lines.length;
|
|
32
|
+
const start = Math.max(0, totalCount - perPage * currentPage);
|
|
33
|
+
const end = Math.max(0, totalCount - perPage * (currentPage - 1));
|
|
34
|
+
const pageData = lines.slice(start, end).map((content, i) => ({
|
|
35
|
+
line: start + i + 1,
|
|
36
|
+
content
|
|
37
|
+
}));
|
|
38
|
+
return { pageData: pageData.reverse(), totalCount };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const readLastLines = async (filePath, n = 100) => {
|
|
42
|
+
const { pageData } = await readLogTail(filePath, { perPage: n, currentPage: 1 });
|
|
43
|
+
return pageData.reverse();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
createLogHub,
|
|
48
|
+
ensureLogFiles,
|
|
49
|
+
appendLog,
|
|
50
|
+
readLogTail,
|
|
51
|
+
readLastLines
|
|
52
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const { Transform } = require('node:stream');
|
|
2
|
+
|
|
3
|
+
const createSseReply = (reply, { heartbeatMs = 15000 } = {}) => {
|
|
4
|
+
reply.hijack();
|
|
5
|
+
reply.raw.writeHead(200, {
|
|
6
|
+
'Content-Type': 'text/event-stream',
|
|
7
|
+
'Cache-Control': 'no-cache',
|
|
8
|
+
Connection: 'keep-alive',
|
|
9
|
+
'X-Accel-Buffering': 'no'
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const writeEvent = (event, data) => {
|
|
13
|
+
if (event) {
|
|
14
|
+
reply.raw.write(`event: ${event}\n`);
|
|
15
|
+
}
|
|
16
|
+
reply.raw.write(`data: ${typeof data === 'string' ? data : JSON.stringify(data)}\n\n`);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const heartbeat = setInterval(() => {
|
|
20
|
+
reply.raw.write(`: ping\n\n`);
|
|
21
|
+
}, heartbeatMs);
|
|
22
|
+
|
|
23
|
+
const close = () => {
|
|
24
|
+
clearInterval(heartbeat);
|
|
25
|
+
try {
|
|
26
|
+
reply.raw.end();
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// ignore
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
reply.raw.on('close', close);
|
|
33
|
+
|
|
34
|
+
return { writeEvent, close, raw: reply.raw };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
createSseReply
|
|
39
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const { Sequelize } = require('sequelize');
|
|
4
|
+
const { listSqlFiles } = require('./validatePackage');
|
|
5
|
+
|
|
6
|
+
const SQL_MIGRATIONS_TABLE = '_fs_sql_migrations';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run .sql files under server/{sqlPath} with the same tracking semantics as @kne/fastify-sequelize.
|
|
10
|
+
*/
|
|
11
|
+
const runSqlMigrations = async ({ serverDir, sqlPath = 'sql', env = {} }) => {
|
|
12
|
+
const sqlDir = path.join(serverDir, sqlPath);
|
|
13
|
+
const files = await listSqlFiles(sqlDir);
|
|
14
|
+
if (!files.length) {
|
|
15
|
+
return { executed: [], skipped: true };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const sequelize = createSequelizeFromEnv(env);
|
|
19
|
+
try {
|
|
20
|
+
await sequelize.authenticate();
|
|
21
|
+
await sequelize.query(
|
|
22
|
+
`CREATE TABLE IF NOT EXISTS ${SQL_MIGRATIONS_TABLE} (
|
|
23
|
+
name VARCHAR(255) PRIMARY KEY,
|
|
24
|
+
executed_at DATETIME
|
|
25
|
+
)`
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const [rows] = await sequelize.query(`SELECT name FROM ${SQL_MIGRATIONS_TABLE}`);
|
|
29
|
+
const done = new Set((rows || []).map(r => r.name));
|
|
30
|
+
const executed = [];
|
|
31
|
+
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
if (done.has(file)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const sql = await fs.readFile(path.join(sqlDir, file), 'utf8');
|
|
37
|
+
await sequelize.query(sql);
|
|
38
|
+
await sequelize.query(`INSERT INTO ${SQL_MIGRATIONS_TABLE} (name, executed_at) VALUES (?, ?)`, {
|
|
39
|
+
replacements: [file, new Date().toISOString()]
|
|
40
|
+
});
|
|
41
|
+
executed.push(file);
|
|
42
|
+
}
|
|
43
|
+
return { executed, skipped: false };
|
|
44
|
+
} finally {
|
|
45
|
+
await sequelize.close();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const createSequelizeFromEnv = env => {
|
|
50
|
+
const dialect = env.DB_DIALECT || 'sqlite';
|
|
51
|
+
if (dialect === 'sqlite') {
|
|
52
|
+
return new Sequelize({
|
|
53
|
+
dialect: 'sqlite',
|
|
54
|
+
storage: env.DB_HOST || env.DB_STORAGE || path.join(process.cwd(), 'data.db'),
|
|
55
|
+
logging: false
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return new Sequelize(env.DB_DATABASE, env.DB_USERNAME, env.DB_PASSWORD, {
|
|
59
|
+
host: env.DB_HOST || '127.0.0.1',
|
|
60
|
+
port: env.DB_PORT ? Number(env.DB_PORT) : undefined,
|
|
61
|
+
dialect,
|
|
62
|
+
logging: false
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
SQL_MIGRATIONS_TABLE,
|
|
68
|
+
runSqlMigrations,
|
|
69
|
+
createSequelizeFromEnv
|
|
70
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const rewriteLocation = (location, mountPrefix) => {
|
|
2
|
+
if (!location || !mountPrefix) {
|
|
3
|
+
return location;
|
|
4
|
+
}
|
|
5
|
+
if (/^https?:\/\//i.test(location) || location.startsWith('//')) {
|
|
6
|
+
return location;
|
|
7
|
+
}
|
|
8
|
+
if (location.startsWith(mountPrefix)) {
|
|
9
|
+
return location;
|
|
10
|
+
}
|
|
11
|
+
if (location.startsWith('/')) {
|
|
12
|
+
return `${mountPrefix}${location}`.replace(/\/{2,}/g, '/').replace(':/', '://');
|
|
13
|
+
}
|
|
14
|
+
return location;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const rewriteSetCookiePath = (setCookie, mountPrefix) => {
|
|
18
|
+
if (!setCookie || !mountPrefix) {
|
|
19
|
+
return setCookie;
|
|
20
|
+
}
|
|
21
|
+
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
22
|
+
return cookies.map(cookie => {
|
|
23
|
+
if (/;\s*Path=/i.test(cookie)) {
|
|
24
|
+
return cookie.replace(/;\s*Path=\/?(?=[;]|$)/i, `; Path=${mountPrefix}`);
|
|
25
|
+
}
|
|
26
|
+
return `${cookie}; Path=${mountPrefix}`;
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TEXTUAL_TYPES = /text\/|javascript|json|xml|svg/i;
|
|
31
|
+
|
|
32
|
+
const shouldRewriteBody = contentType => TEXTUAL_TYPES.test(contentType || '');
|
|
33
|
+
|
|
34
|
+
const rewriteBody = (body, mountPrefix, prefixes = ['/static', '/api', '/account']) => {
|
|
35
|
+
if (!body || !mountPrefix) {
|
|
36
|
+
return body;
|
|
37
|
+
}
|
|
38
|
+
let text = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
|
|
39
|
+
for (const p of prefixes) {
|
|
40
|
+
const from = `"${p}/`;
|
|
41
|
+
const to = `"${mountPrefix}${p}/`;
|
|
42
|
+
text = text.split(from).join(to);
|
|
43
|
+
const from2 = `'${p}/`;
|
|
44
|
+
const to2 = `'${mountPrefix}${p}/`;
|
|
45
|
+
text = text.split(from2).join(to2);
|
|
46
|
+
}
|
|
47
|
+
return text;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
rewriteLocation,
|
|
52
|
+
rewriteSetCookiePath,
|
|
53
|
+
shouldRewriteBody,
|
|
54
|
+
rewriteBody
|
|
55
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const pm2 = require('pm2');
|
|
3
|
+
const { mergePm2Config } = require('./env');
|
|
4
|
+
|
|
5
|
+
const connect = () =>
|
|
6
|
+
new Promise((resolve, reject) => {
|
|
7
|
+
pm2.connect(err => (err ? reject(err) : resolve()));
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const disconnect = () => {
|
|
11
|
+
try {
|
|
12
|
+
pm2.disconnect();
|
|
13
|
+
} catch (e) {
|
|
14
|
+
// ignore
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const describe = name =>
|
|
19
|
+
new Promise((resolve, reject) => {
|
|
20
|
+
pm2.describe(name, (err, list) => {
|
|
21
|
+
if (err) {
|
|
22
|
+
return reject(err);
|
|
23
|
+
}
|
|
24
|
+
resolve(list || []);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const deleteProcess = name =>
|
|
29
|
+
new Promise(resolve => {
|
|
30
|
+
pm2.delete(name, () => resolve());
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const startApp = async ({ pm2Name, cwd, env, pm2Defaults, pm2Config, outFile, errorFile }) => {
|
|
34
|
+
const cfg = mergePm2Config(pm2Defaults, pm2Config);
|
|
35
|
+
const existing = await describe(pm2Name);
|
|
36
|
+
if (existing.length) {
|
|
37
|
+
await deleteProcess(pm2Name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const script = path.join(cwd, 'index.js');
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
pm2.start(
|
|
43
|
+
{
|
|
44
|
+
name: pm2Name,
|
|
45
|
+
script,
|
|
46
|
+
cwd,
|
|
47
|
+
env,
|
|
48
|
+
exec_mode: cfg.exec_mode,
|
|
49
|
+
instances: cfg.instances,
|
|
50
|
+
autorestart: cfg.autorestart,
|
|
51
|
+
max_memory_restart: cfg.max_memory_restart,
|
|
52
|
+
max_restarts: cfg.max_restarts,
|
|
53
|
+
min_uptime: cfg.min_uptime,
|
|
54
|
+
kill_timeout: cfg.kill_timeout,
|
|
55
|
+
merge_logs: cfg.merge_logs,
|
|
56
|
+
out_file: outFile,
|
|
57
|
+
error_file: errorFile,
|
|
58
|
+
time: true
|
|
59
|
+
},
|
|
60
|
+
(err, apps) => (err ? reject(err) : resolve(apps))
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const stopApp = name =>
|
|
66
|
+
new Promise((resolve, reject) => {
|
|
67
|
+
pm2.stop(name, err => (err ? reject(err) : resolve()));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const restartApp = name =>
|
|
71
|
+
new Promise((resolve, reject) => {
|
|
72
|
+
pm2.restart(name, err => (err ? reject(err) : resolve()));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const list = () =>
|
|
76
|
+
new Promise((resolve, reject) => {
|
|
77
|
+
pm2.list((err, list) => (err ? reject(err) : resolve(list || [])));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const launchBus = () =>
|
|
81
|
+
new Promise((resolve, reject) => {
|
|
82
|
+
pm2.launchBus((err, bus) => (err ? reject(err) : resolve(bus)));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
connect,
|
|
87
|
+
disconnect,
|
|
88
|
+
describe,
|
|
89
|
+
deleteProcess,
|
|
90
|
+
startApp,
|
|
91
|
+
stopApp,
|
|
92
|
+
restartApp,
|
|
93
|
+
list,
|
|
94
|
+
launchBus,
|
|
95
|
+
raw: pm2
|
|
96
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const net = require('node:net');
|
|
2
|
+
|
|
3
|
+
const isPortFree = port =>
|
|
4
|
+
new Promise(resolve => {
|
|
5
|
+
const server = net.createServer();
|
|
6
|
+
server.unref();
|
|
7
|
+
server.once('error', () => resolve(false));
|
|
8
|
+
server.once('listening', () => {
|
|
9
|
+
server.close(() => resolve(true));
|
|
10
|
+
});
|
|
11
|
+
server.listen(port, '127.0.0.1');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
isPortFree,
|
|
16
|
+
async allocate({ models, portMin, portMax, excludePorts = [] }) {
|
|
17
|
+
const rows = await models.app.findAll({ attributes: ['port'] });
|
|
18
|
+
const used = new Set([...rows.map(r => r.port), ...excludePorts]);
|
|
19
|
+
for (let port = portMin; port <= portMax; port++) {
|
|
20
|
+
if (used.has(port)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (await isPortFree(port)) {
|
|
24
|
+
return port;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`No free port in range ${portMin}-${portMax}`);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
|
|
4
|
+
const ENTRY_CANDIDATES = ['index.html', 'entry.html', 'entry-prod.html'];
|
|
5
|
+
|
|
6
|
+
const validatePackageRoot = async rootDir => {
|
|
7
|
+
const errors = [];
|
|
8
|
+
const rootPkg = path.join(rootDir, 'package.json');
|
|
9
|
+
const serverPkg = path.join(rootDir, 'server', 'package.json');
|
|
10
|
+
const serverIndex = path.join(rootDir, 'server', 'index.js');
|
|
11
|
+
const buildDir = path.join(rootDir, 'build');
|
|
12
|
+
|
|
13
|
+
if (!(await fs.pathExists(rootPkg))) {
|
|
14
|
+
errors.push('missing root package.json');
|
|
15
|
+
}
|
|
16
|
+
if (!(await fs.pathExists(serverPkg))) {
|
|
17
|
+
errors.push('missing server/package.json');
|
|
18
|
+
}
|
|
19
|
+
if (!(await fs.pathExists(serverIndex))) {
|
|
20
|
+
errors.push('missing server/index.js');
|
|
21
|
+
}
|
|
22
|
+
if (!(await fs.pathExists(buildDir))) {
|
|
23
|
+
errors.push('missing root build/ directory');
|
|
24
|
+
} else {
|
|
25
|
+
const hasEntry = (await Promise.all(ENTRY_CANDIDATES.map(name => fs.pathExists(path.join(buildDir, name))))).some(Boolean);
|
|
26
|
+
if (!hasEntry) {
|
|
27
|
+
errors.push(`build/ must contain one of: ${ENTRY_CANDIDATES.join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
ok: errors.length === 0,
|
|
33
|
+
errors,
|
|
34
|
+
hasMigration: await hasSqlMigrations(path.join(rootDir, 'server', 'sql'))
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const hasSqlMigrations = async sqlDir => {
|
|
39
|
+
if (!(await fs.pathExists(sqlDir))) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const files = await fs.readdir(sqlDir);
|
|
43
|
+
return files.some(f => f.endsWith('.sql'));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const listSqlFiles = async sqlDir => {
|
|
47
|
+
if (!(await fs.pathExists(sqlDir))) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const files = await fs.readdir(sqlDir);
|
|
51
|
+
return files.filter(f => f.endsWith('.sql')).sort();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
ENTRY_CANDIDATES,
|
|
56
|
+
validatePackageRoot,
|
|
57
|
+
hasSqlMigrations,
|
|
58
|
+
listSqlFiles
|
|
59
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const { extractZipSafe, resolvePackageRoot } = require('./zip');
|
|
5
|
+
const { validatePackageRoot, listSqlFiles } = require('./validatePackage');
|
|
6
|
+
|
|
7
|
+
const runNpmInstall = (cwd, timeoutMs) =>
|
|
8
|
+
new Promise((resolve, reject) => {
|
|
9
|
+
const child = spawn('npm', ['install', '--production', '--no-fund', '--no-audit'], {
|
|
10
|
+
cwd,
|
|
11
|
+
env: process.env,
|
|
12
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
13
|
+
});
|
|
14
|
+
let stderr = '';
|
|
15
|
+
child.stderr.on('data', chunk => {
|
|
16
|
+
stderr += chunk.toString();
|
|
17
|
+
});
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
child.kill('SIGKILL');
|
|
20
|
+
reject(new Error(`npm install timed out after ${timeoutMs}ms`));
|
|
21
|
+
}, timeoutMs);
|
|
22
|
+
child.on('error', err => {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
reject(err);
|
|
25
|
+
});
|
|
26
|
+
child.on('close', code => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
if (code === 0) {
|
|
29
|
+
resolve();
|
|
30
|
+
} else {
|
|
31
|
+
reject(new Error(`npm install failed (${code}): ${stderr.slice(-2000)}`));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath }) => {
|
|
37
|
+
await fs.emptyDir(artifactPath);
|
|
38
|
+
const extractDir = path.join(artifactPath, '_extract');
|
|
39
|
+
await extractZipSafe(zipBuffer, extractDir, { maxZipSize, maxZipEntries });
|
|
40
|
+
const packageRoot = await resolvePackageRoot(extractDir);
|
|
41
|
+
|
|
42
|
+
const validation = await validatePackageRoot(packageRoot);
|
|
43
|
+
if (!validation.ok) {
|
|
44
|
+
await fs.remove(artifactPath);
|
|
45
|
+
throw new Error(`invalid package: ${validation.errors.join('; ')}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Move package contents to artifact root
|
|
49
|
+
const staging = path.join(artifactPath, '_pkg');
|
|
50
|
+
await fs.move(packageRoot, staging, { overwrite: true });
|
|
51
|
+
await fs.remove(extractDir);
|
|
52
|
+
const entries = await fs.readdir(staging);
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
await fs.move(path.join(staging, entry), path.join(artifactPath, entry), { overwrite: true });
|
|
55
|
+
}
|
|
56
|
+
await fs.remove(staging);
|
|
57
|
+
|
|
58
|
+
await fs.copy(path.join(artifactPath, 'build'), path.join(artifactPath, 'server', 'build'), {
|
|
59
|
+
overwrite: true
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const serverDir = path.join(artifactPath, 'server');
|
|
63
|
+
await runNpmInstall(serverDir, npmInstallTimeoutMs);
|
|
64
|
+
|
|
65
|
+
const sqlDir = path.join(serverDir, sqlPath || 'sql');
|
|
66
|
+
const sqlFiles = await listSqlFiles(sqlDir);
|
|
67
|
+
return {
|
|
68
|
+
hasMigration: sqlFiles.length > 0,
|
|
69
|
+
migrationPath: sqlPath || 'sql',
|
|
70
|
+
sqlFiles
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
runNpmInstall,
|
|
76
|
+
prepareVersionArtifact
|
|
77
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const AdmZip = require('adm-zip');
|
|
4
|
+
|
|
5
|
+
const isPathInside = (parent, child) => {
|
|
6
|
+
const relative = path.relative(parent, child);
|
|
7
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const extractZipSafe = async (zipBuffer, destDir, { maxZipSize, maxZipEntries }) => {
|
|
11
|
+
if (!Buffer.isBuffer(zipBuffer)) {
|
|
12
|
+
throw new Error('zip content must be a Buffer');
|
|
13
|
+
}
|
|
14
|
+
if (zipBuffer.length > maxZipSize) {
|
|
15
|
+
throw new Error(`zip exceeds max size ${maxZipSize}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
await fs.ensureDir(destDir);
|
|
19
|
+
const zip = new AdmZip(zipBuffer);
|
|
20
|
+
const entries = zip.getEntries();
|
|
21
|
+
if (entries.length > maxZipEntries) {
|
|
22
|
+
throw new Error(`zip has too many entries (${entries.length} > ${maxZipEntries})`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const entryName = entry.entryName.replace(/^[/\\]+/, '').replace(/\\/g, '/');
|
|
27
|
+
if (!entryName || entryName.includes('\0')) {
|
|
28
|
+
throw new Error('invalid zip entry name');
|
|
29
|
+
}
|
|
30
|
+
if (entryName.split('/').some(part => part === '..')) {
|
|
31
|
+
throw new Error(`zip slip detected: ${entry.entryName}`);
|
|
32
|
+
}
|
|
33
|
+
const target = path.resolve(destDir, entryName);
|
|
34
|
+
if (!isPathInside(path.resolve(destDir), target)) {
|
|
35
|
+
throw new Error(`zip slip detected: ${entry.entryName}`);
|
|
36
|
+
}
|
|
37
|
+
if (entry.isDirectory) {
|
|
38
|
+
await fs.ensureDir(target);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
await fs.ensureDir(path.dirname(target));
|
|
42
|
+
await fs.writeFile(target, entry.getData());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// If zip has a single top-level folder, flatten optional? Keep as-is; callers resolve package root.
|
|
46
|
+
return destDir;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const resolvePackageRoot = async extractDir => {
|
|
50
|
+
const direct = path.join(extractDir, 'package.json');
|
|
51
|
+
if (await fs.pathExists(direct)) {
|
|
52
|
+
return extractDir;
|
|
53
|
+
}
|
|
54
|
+
const children = await fs.readdir(extractDir);
|
|
55
|
+
for (const child of children) {
|
|
56
|
+
const candidate = path.join(extractDir, child);
|
|
57
|
+
const stat = await fs.stat(candidate);
|
|
58
|
+
if (stat.isDirectory() && (await fs.pathExists(path.join(candidate, 'package.json')))) {
|
|
59
|
+
return candidate;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return extractDir;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
module.exports = {
|
|
66
|
+
isPathInside,
|
|
67
|
+
extractZipSafe,
|
|
68
|
+
resolvePackageRoot
|
|
69
|
+
};
|