@naisys/common-node 3.0.0-beta.5 → 3.0.0-beta.7
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/dist/agentConfigLoader.js +67 -80
- package/dist/bearerToken.js +3 -2
- package/dist/customModelsLoader.js +31 -31
- package/dist/expandEnv.js +3 -6
- package/dist/hashToken.js +1 -1
- package/dist/hubCertVerification.js +40 -46
- package/dist/logFileService.js +51 -48
- package/dist/migrationHelper.js +101 -109
- package/dist/sessionCookie.js +7 -7
- package/package.json +2 -2
|
@@ -4,90 +4,77 @@ import yaml from "js-yaml";
|
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
/** Loads agent yaml configs from a file or directory path, returns a map of userId → UserEntry */
|
|
6
6
|
export function loadAgentConfigs(startupPath) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
(e) => e.username === adminAgentConfig.username
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
return userMap;
|
|
7
|
+
const configEntries = [];
|
|
8
|
+
const usernameToPath = new Map();
|
|
9
|
+
const resolvedPath = path.resolve(startupPath);
|
|
10
|
+
if (fs.statSync(resolvedPath).isDirectory()) {
|
|
11
|
+
processDirectory(resolvedPath, undefined, configEntries, usernameToPath);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
processFile(resolvedPath, undefined, configEntries, usernameToPath);
|
|
15
|
+
}
|
|
16
|
+
// Add admin if not present
|
|
17
|
+
const hasAdmin = configEntries.some((e) => e.username === adminAgentConfig.username);
|
|
18
|
+
if (!hasAdmin) {
|
|
19
|
+
configEntries.push({
|
|
20
|
+
username: adminAgentConfig.username,
|
|
21
|
+
leadEntryIndex: undefined,
|
|
22
|
+
config: adminAgentConfig,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// Build userId map (1-based sequential IDs)
|
|
26
|
+
const userMap = new Map();
|
|
27
|
+
for (let i = 0; i < configEntries.length; i++) {
|
|
28
|
+
const entry = configEntries[i];
|
|
29
|
+
const userId = i + 1;
|
|
30
|
+
const leadUserId = entry.leadEntryIndex !== undefined ? entry.leadEntryIndex + 1 : undefined;
|
|
31
|
+
userMap.set(userId, {
|
|
32
|
+
userId,
|
|
33
|
+
username: entry.username,
|
|
34
|
+
enabled: true,
|
|
35
|
+
leadUserId,
|
|
36
|
+
config: entry.config,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return userMap;
|
|
42
40
|
}
|
|
43
|
-
function processDirectory(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const files = fs.readdirSync(dirPath);
|
|
50
|
-
for (const file of files) {
|
|
51
|
-
if (file.endsWith(".yaml") || file.endsWith(".yml")) {
|
|
52
|
-
processFile(
|
|
53
|
-
path.join(dirPath, file),
|
|
54
|
-
leadEntryIndex,
|
|
55
|
-
configEntries,
|
|
56
|
-
usernameToPath,
|
|
57
|
-
);
|
|
41
|
+
function processDirectory(dirPath, leadEntryIndex, configEntries, usernameToPath) {
|
|
42
|
+
const files = fs.readdirSync(dirPath);
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
if (file.endsWith(".yaml") || file.endsWith(".yml")) {
|
|
45
|
+
processFile(path.join(dirPath, file), leadEntryIndex, configEntries, usernameToPath);
|
|
46
|
+
}
|
|
58
47
|
}
|
|
59
|
-
}
|
|
60
48
|
}
|
|
61
49
|
function processFile(filePath, leadEntryIndex, configEntries, usernameToPath) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
50
|
+
const absolutePath = path.resolve(filePath);
|
|
51
|
+
try {
|
|
52
|
+
const configYaml = fs.readFileSync(absolutePath, "utf8");
|
|
53
|
+
const configObj = yaml.load(configYaml);
|
|
54
|
+
const agentConfig = AgentConfigFileSchema.parse(configObj);
|
|
55
|
+
const username = agentConfig.username;
|
|
56
|
+
// Check for duplicate usernames from different files
|
|
57
|
+
const existingPath = usernameToPath.get(username);
|
|
58
|
+
if (existingPath && existingPath !== absolutePath) {
|
|
59
|
+
throw new Error(`Duplicate username "${username}" found in multiple files:\n ${existingPath}\n ${absolutePath}`);
|
|
60
|
+
}
|
|
61
|
+
usernameToPath.set(username, absolutePath);
|
|
62
|
+
const currentIndex = configEntries.length;
|
|
63
|
+
configEntries.push({
|
|
64
|
+
username,
|
|
65
|
+
leadEntryIndex,
|
|
66
|
+
config: agentConfig,
|
|
67
|
+
});
|
|
68
|
+
console.log(`Loaded user: ${username} from ${filePath}`);
|
|
69
|
+
// Check for a subdirectory matching the filename (without extension)
|
|
70
|
+
const ext = path.extname(absolutePath);
|
|
71
|
+
const baseName = path.basename(absolutePath, ext);
|
|
72
|
+
const subDir = path.join(path.dirname(absolutePath), baseName);
|
|
73
|
+
if (fs.existsSync(subDir) && fs.statSync(subDir).isDirectory()) {
|
|
74
|
+
processDirectory(subDir, currentIndex, configEntries, usernameToPath);
|
|
75
|
+
}
|
|
74
76
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
configEntries.push({
|
|
78
|
-
username,
|
|
79
|
-
leadEntryIndex,
|
|
80
|
-
config: agentConfig,
|
|
81
|
-
});
|
|
82
|
-
console.log(`Loaded user: ${username} from ${filePath}`);
|
|
83
|
-
// Check for a subdirectory matching the filename (without extension)
|
|
84
|
-
const ext = path.extname(absolutePath);
|
|
85
|
-
const baseName = path.basename(absolutePath, ext);
|
|
86
|
-
const subDir = path.join(path.dirname(absolutePath), baseName);
|
|
87
|
-
if (fs.existsSync(subDir) && fs.statSync(subDir).isDirectory()) {
|
|
88
|
-
processDirectory(subDir, currentIndex, configEntries, usernameToPath);
|
|
77
|
+
catch (e) {
|
|
78
|
+
throw new Error(`Failed to process agent config at ${filePath}: ${e}`);
|
|
89
79
|
}
|
|
90
|
-
} catch (e) {
|
|
91
|
-
throw new Error(`Failed to process agent config at ${filePath}: ${e}`);
|
|
92
|
-
}
|
|
93
80
|
}
|
package/dist/bearerToken.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Returns undefined if the header is missing or not in Bearer format.
|
|
4
4
|
*/
|
|
5
5
|
export function extractBearerToken(authHeader) {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
if (!authHeader?.startsWith("Bearer "))
|
|
7
|
+
return undefined;
|
|
8
|
+
return authHeader.slice(7);
|
|
8
9
|
}
|
|
@@ -3,37 +3,37 @@ import fs from "fs";
|
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import path from "path";
|
|
5
5
|
export function loadCustomModels(folder) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
6
|
+
if (!folder) {
|
|
7
|
+
return { llmModels: [], imageModels: [] };
|
|
8
|
+
}
|
|
9
|
+
const filePath = path.join(folder, "custom-models.yaml");
|
|
10
|
+
if (!fs.existsSync(filePath)) {
|
|
11
|
+
return { llmModels: [], imageModels: [] };
|
|
12
|
+
}
|
|
13
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
14
|
+
const parsed = yaml.load(raw);
|
|
15
|
+
const result = CustomModelsFileSchema.parse(parsed);
|
|
16
|
+
return {
|
|
17
|
+
llmModels: result.llmModels ?? [],
|
|
18
|
+
imageModels: result.imageModels ?? [],
|
|
19
|
+
};
|
|
20
20
|
}
|
|
21
21
|
export function saveCustomModels(data) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
22
|
+
const folder = process.env.NAISYS_FOLDER;
|
|
23
|
+
if (!folder) {
|
|
24
|
+
throw new Error("NAISYS_FOLDER environment variable is not set");
|
|
25
|
+
}
|
|
26
|
+
// Validate before writing
|
|
27
|
+
CustomModelsFileSchema.parse(data);
|
|
28
|
+
// Omit empty arrays from output
|
|
29
|
+
const output = {};
|
|
30
|
+
if (data.llmModels && data.llmModels.length > 0) {
|
|
31
|
+
output.llmModels = data.llmModels;
|
|
32
|
+
}
|
|
33
|
+
if (data.imageModels && data.imageModels.length > 0) {
|
|
34
|
+
output.imageModels = data.imageModels;
|
|
35
|
+
}
|
|
36
|
+
const filePath = path.join(folder, "custom-models.yaml");
|
|
37
|
+
const yamlStr = yaml.dump(output, { lineWidth: -1 });
|
|
38
|
+
fs.writeFileSync(filePath, yamlStr, "utf-8");
|
|
39
39
|
}
|
package/dist/expandEnv.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import os from "os";
|
|
2
2
|
/** Expand ~ to the user's home directory in NAISYS_FOLDER */
|
|
3
3
|
export function expandNaisysFolder() {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
os.homedir(),
|
|
8
|
-
);
|
|
9
|
-
}
|
|
4
|
+
if (process.env.NAISYS_FOLDER?.startsWith("~")) {
|
|
5
|
+
process.env.NAISYS_FOLDER = process.env.NAISYS_FOLDER.replace("~", os.homedir());
|
|
6
|
+
}
|
|
10
7
|
}
|
package/dist/hashToken.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "crypto";
|
|
2
2
|
/** Hash a token (session cookie, API key) with SHA-256 for safe cache keys / DB lookup. */
|
|
3
3
|
export function hashToken(token) {
|
|
4
|
-
|
|
4
|
+
return createHash("sha256").update(token).digest("hex");
|
|
5
5
|
}
|
|
@@ -8,34 +8,33 @@ import tls from "tls";
|
|
|
8
8
|
* Returns undefined if the file does not exist.
|
|
9
9
|
*/
|
|
10
10
|
export function readHubAccessKeyFile() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const naisysFolder = process.env.NAISYS_FOLDER || "";
|
|
12
|
+
const accessKeyPath = join(naisysFolder, "cert", "hub-access-key");
|
|
13
|
+
if (!existsSync(accessKeyPath))
|
|
14
|
+
return undefined;
|
|
15
|
+
return readFileSync(accessKeyPath, "utf-8").trim();
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
18
|
* Resolve the hub access key from environment variable or local cert file.
|
|
18
19
|
* Returns undefined if neither is available.
|
|
19
20
|
*/
|
|
20
21
|
export function resolveHubAccessKey() {
|
|
21
|
-
|
|
22
|
+
return process.env.HUB_ACCESS_KEY || readHubAccessKeyFile();
|
|
22
23
|
}
|
|
23
24
|
/** Parse a hub access key in format "<fingerprintPrefix>+<secret>" */
|
|
24
25
|
export function parseHubAccessKey(accessKey) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
secret: accessKey.substring(plusIndex + 1),
|
|
34
|
-
};
|
|
26
|
+
const plusIndex = accessKey.indexOf("+");
|
|
27
|
+
if (plusIndex === -1) {
|
|
28
|
+
throw new Error(`Invalid hub access key format, expected <fingerprint>+<secret>, got ${accessKey}`);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
fingerprintPrefix: accessKey.substring(0, plusIndex),
|
|
32
|
+
secret: accessKey.substring(plusIndex + 1),
|
|
33
|
+
};
|
|
35
34
|
}
|
|
36
35
|
/** Compute SHA-256 fingerprint of a DER-encoded certificate */
|
|
37
36
|
export function computeCertFingerprint(derCert) {
|
|
38
|
-
|
|
37
|
+
return createHash("sha256").update(derCert).digest("hex");
|
|
39
38
|
}
|
|
40
39
|
/**
|
|
41
40
|
* Connect to a TLS server, verify that the certificate fingerprint matches
|
|
@@ -43,32 +42,27 @@ export function computeCertFingerprint(derCert) {
|
|
|
43
42
|
* trusted CA in subsequent connections.
|
|
44
43
|
*/
|
|
45
44
|
export async function verifyHubCertificate(host, port, fingerprintPrefix) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
cert.raw.toString("base64") +
|
|
67
|
-
"\n-----END CERTIFICATE-----\n";
|
|
68
|
-
resolve(pem);
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const sock = tls.connect(port, host, { rejectUnauthorized: false }, () => {
|
|
47
|
+
const cert = sock.getPeerCertificate(true);
|
|
48
|
+
sock.destroy();
|
|
49
|
+
if (!cert?.raw) {
|
|
50
|
+
reject(new Error("No certificate received from hub"));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const fingerprint = computeCertFingerprint(cert.raw);
|
|
54
|
+
if (!fingerprint.startsWith(fingerprintPrefix)) {
|
|
55
|
+
reject(new Error(`Hub certificate fingerprint mismatch: expected prefix ${fingerprintPrefix}, got ${fingerprint.substring(0, fingerprintPrefix.length)}`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Convert DER to PEM so it can be used as a trusted CA
|
|
59
|
+
const pem = "-----BEGIN CERTIFICATE-----\n" +
|
|
60
|
+
cert.raw.toString("base64") +
|
|
61
|
+
"\n-----END CERTIFICATE-----\n";
|
|
62
|
+
resolve(pem);
|
|
63
|
+
});
|
|
64
|
+
sock.on("error", reject);
|
|
69
65
|
});
|
|
70
|
-
sock.on("error", reject);
|
|
71
|
-
});
|
|
72
66
|
}
|
|
73
67
|
/**
|
|
74
68
|
* Create an https.Agent that trusts only the given PEM certificate.
|
|
@@ -76,9 +70,9 @@ export async function verifyHubCertificate(host, port, fingerprintPrefix) {
|
|
|
76
70
|
* to the same cert (no TOCTOU gap since Node's TLS layer enforces it).
|
|
77
71
|
*/
|
|
78
72
|
export function createPinnedHttpsAgent(certPem) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
73
|
+
return new https.Agent({
|
|
74
|
+
ca: certPem,
|
|
75
|
+
// Skip hostname check — cert is already pinned by fingerprint
|
|
76
|
+
checkServerIdentity: () => undefined,
|
|
77
|
+
});
|
|
84
78
|
}
|
package/dist/logFileService.js
CHANGED
|
@@ -1,55 +1,58 @@
|
|
|
1
1
|
import fsp from "node:fs/promises";
|
|
2
2
|
const MAX_READ_BYTES = 256 * 1024;
|
|
3
3
|
export async function tailLogFile(filePath, lineCount, minLevel) {
|
|
4
|
-
|
|
5
|
-
try {
|
|
6
|
-
stat = await fsp.stat(filePath);
|
|
7
|
-
} catch {
|
|
8
|
-
return { entries: [], fileSize: 0 };
|
|
9
|
-
}
|
|
10
|
-
const fileSize = stat.size;
|
|
11
|
-
if (fileSize === 0) {
|
|
12
|
-
return { entries: [], fileSize: 0 };
|
|
13
|
-
}
|
|
14
|
-
const readSize = Math.min(fileSize, MAX_READ_BYTES);
|
|
15
|
-
const position = fileSize - readSize;
|
|
16
|
-
const buffer = Buffer.alloc(readSize);
|
|
17
|
-
const handle = await fsp.open(filePath, "r");
|
|
18
|
-
try {
|
|
19
|
-
await handle.read(buffer, 0, readSize, position);
|
|
20
|
-
} finally {
|
|
21
|
-
await handle.close();
|
|
22
|
-
}
|
|
23
|
-
const text = buffer.toString("utf-8");
|
|
24
|
-
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
25
|
-
// If we didn't read from the start, drop the first line (likely partial)
|
|
26
|
-
if (position > 0 && lines.length > 0) {
|
|
27
|
-
lines.shift();
|
|
28
|
-
}
|
|
29
|
-
const OMIT_KEYS = new Set(["level", "time", "msg", "pid", "hostname"]);
|
|
30
|
-
const entries = [];
|
|
31
|
-
for (const line of lines) {
|
|
4
|
+
let stat;
|
|
32
5
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
6
|
+
stat = await fsp.stat(filePath);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return { entries: [], fileSize: 0 };
|
|
10
|
+
}
|
|
11
|
+
const fileSize = stat.size;
|
|
12
|
+
if (fileSize === 0) {
|
|
13
|
+
return { entries: [], fileSize: 0 };
|
|
14
|
+
}
|
|
15
|
+
const readSize = Math.min(fileSize, MAX_READ_BYTES);
|
|
16
|
+
const position = fileSize - readSize;
|
|
17
|
+
const buffer = Buffer.alloc(readSize);
|
|
18
|
+
const handle = await fsp.open(filePath, "r");
|
|
19
|
+
try {
|
|
20
|
+
await handle.read(buffer, 0, readSize, position);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
await handle.close();
|
|
24
|
+
}
|
|
25
|
+
const text = buffer.toString("utf-8");
|
|
26
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
27
|
+
// If we didn't read from the start, drop the first line (likely partial)
|
|
28
|
+
if (position > 0 && lines.length > 0) {
|
|
29
|
+
lines.shift();
|
|
30
|
+
}
|
|
31
|
+
const OMIT_KEYS = new Set(["level", "time", "msg", "pid", "hostname"]);
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(line);
|
|
36
|
+
const level = parsed.level ?? 30;
|
|
37
|
+
if (minLevel != null && level < minLevel)
|
|
38
|
+
continue;
|
|
39
|
+
const extra = {};
|
|
40
|
+
for (const key of Object.keys(parsed)) {
|
|
41
|
+
if (!OMIT_KEYS.has(key)) {
|
|
42
|
+
extra[key] = parsed[key];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const detail = Object.keys(extra).length > 0 ? JSON.stringify(extra) : undefined;
|
|
46
|
+
entries.push({
|
|
47
|
+
level,
|
|
48
|
+
time: parsed.time ?? 0,
|
|
49
|
+
msg: parsed.msg ?? "",
|
|
50
|
+
detail,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// skip malformed lines
|
|
40
55
|
}
|
|
41
|
-
}
|
|
42
|
-
const detail =
|
|
43
|
-
Object.keys(extra).length > 0 ? JSON.stringify(extra) : undefined;
|
|
44
|
-
entries.push({
|
|
45
|
-
level,
|
|
46
|
-
time: parsed.time ?? 0,
|
|
47
|
-
msg: parsed.msg ?? "",
|
|
48
|
-
detail,
|
|
49
|
-
});
|
|
50
|
-
} catch {
|
|
51
|
-
// skip malformed lines
|
|
52
56
|
}
|
|
53
|
-
|
|
54
|
-
return { entries: entries.slice(-lineCount), fileSize };
|
|
57
|
+
return { entries: entries.slice(-lineCount), fileSize };
|
|
55
58
|
}
|
package/dist/migrationHelper.js
CHANGED
|
@@ -9,121 +9,113 @@ const execAsync = promisify(exec);
|
|
|
9
9
|
* Uses `better-sqlite3` directly (synchronous, no Prisma dependency) for the version check.
|
|
10
10
|
*/
|
|
11
11
|
export async function deployPrismaMigrations(options) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
let currentVersion;
|
|
19
|
-
// Check version if database file already exists
|
|
20
|
-
if (existsSync(databasePath)) {
|
|
21
|
-
const db = new Database(databasePath);
|
|
22
|
-
try {
|
|
23
|
-
const row = db
|
|
24
|
-
.prepare("SELECT version FROM schema_version WHERE id = 1")
|
|
25
|
-
.get();
|
|
26
|
-
currentVersion = row?.version;
|
|
27
|
-
} catch {
|
|
28
|
-
// "no such table" → treat as new DB, proceed with migration
|
|
12
|
+
const { packageDir, databasePath, expectedVersion, envOverrides } = options;
|
|
13
|
+
// Ensure database directory exists
|
|
14
|
+
const databaseDir = dirname(databasePath);
|
|
15
|
+
if (!existsSync(databaseDir)) {
|
|
16
|
+
mkdirSync(databaseDir, { recursive: true });
|
|
29
17
|
}
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
18
|
+
let currentVersion;
|
|
19
|
+
// Check version if database file already exists
|
|
20
|
+
if (existsSync(databasePath)) {
|
|
21
|
+
const db = new Database(databasePath);
|
|
22
|
+
try {
|
|
23
|
+
const row = db
|
|
24
|
+
.prepare("SELECT version FROM schema_version WHERE id = 1")
|
|
25
|
+
.get();
|
|
26
|
+
currentVersion = row?.version;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// "no such table" → treat as new DB, proceed with migration
|
|
30
|
+
}
|
|
31
|
+
// Switch from WAL to DELETE journal mode before closing. This merges any
|
|
32
|
+
// pending WAL data and removes the -wal/-shm files entirely. Without this,
|
|
33
|
+
// prisma migrate (a separate process) sees the leftover SHM file and fails
|
|
34
|
+
// with "database is locked".
|
|
35
|
+
try {
|
|
36
|
+
db.pragma("journal_mode=DELETE");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Failed — another process may genuinely hold the lock
|
|
40
|
+
}
|
|
41
|
+
db.close();
|
|
42
|
+
if (currentVersion === expectedVersion) {
|
|
43
|
+
return; // Fast path — already at expected version
|
|
44
|
+
}
|
|
38
45
|
}
|
|
39
|
-
|
|
40
|
-
if (currentVersion
|
|
41
|
-
|
|
46
|
+
// Log migration status
|
|
47
|
+
if (currentVersion !== undefined) {
|
|
48
|
+
if (currentVersion > expectedVersion) {
|
|
49
|
+
throw new Error(`Database version ${currentVersion} is newer than expected ${expectedVersion}. Manual intervention required.`);
|
|
50
|
+
}
|
|
51
|
+
console.log(`Migrating database from version ${currentVersion} to ${expectedVersion}...`);
|
|
42
52
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (currentVersion !== undefined) {
|
|
46
|
-
if (currentVersion > expectedVersion) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
`Database version ${currentVersion} is newer than expected ${expectedVersion}. Manual intervention required.`,
|
|
49
|
-
);
|
|
53
|
+
else {
|
|
54
|
+
console.log(`Creating new database with schema version ${expectedVersion}...`);
|
|
50
55
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
// Run prisma migrate deploy
|
|
60
|
-
const schemaPath = join(packageDir, "prisma", "schema.prisma");
|
|
61
|
-
const absoluteDbPath = resolve(databasePath).replace(/\\/g, "/");
|
|
62
|
-
let stdout;
|
|
63
|
-
let stderr;
|
|
64
|
-
try {
|
|
65
|
-
({ stdout, stderr } = await execAsync(
|
|
66
|
-
`npx prisma migrate deploy --schema="${schemaPath}"`,
|
|
67
|
-
{
|
|
68
|
-
cwd: packageDir,
|
|
69
|
-
env: {
|
|
70
|
-
...process.env,
|
|
71
|
-
// Resolve to absolute so prisma.config.ts gets a correct path
|
|
72
|
-
// regardless of this subprocess's cwd (which is packageDir)
|
|
73
|
-
NAISYS_FOLDER: resolve(process.env.NAISYS_FOLDER || ""),
|
|
74
|
-
...envOverrides,
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
));
|
|
78
|
-
} catch (error) {
|
|
79
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
80
|
-
if (msg.includes("database is locked")) {
|
|
81
|
-
// Stale WAL/SHM files from a crashed process — remove and retry
|
|
82
|
-
const walPath = absoluteDbPath + "-wal";
|
|
83
|
-
const shmPath = absoluteDbPath + "-shm";
|
|
84
|
-
let removed = false;
|
|
85
|
-
for (const staleFile of [walPath, shmPath]) {
|
|
86
|
-
if (existsSync(staleFile)) {
|
|
87
|
-
console.log(`Removing stale file: ${staleFile}`);
|
|
88
|
-
unlinkSync(staleFile);
|
|
89
|
-
removed = true;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (removed) {
|
|
93
|
-
console.log("Retrying migration after removing stale WAL files...");
|
|
94
|
-
({ stdout, stderr } = await execAsync(
|
|
95
|
-
`npx prisma migrate deploy --schema="${schemaPath}"`,
|
|
96
|
-
{
|
|
56
|
+
// Run prisma migrate deploy
|
|
57
|
+
const schemaPath = join(packageDir, "prisma", "schema.prisma");
|
|
58
|
+
const absoluteDbPath = resolve(databasePath).replace(/\\/g, "/");
|
|
59
|
+
let stdout;
|
|
60
|
+
let stderr;
|
|
61
|
+
try {
|
|
62
|
+
({ stdout, stderr } = await execAsync(`npx prisma migrate deploy --schema="${schemaPath}"`, {
|
|
97
63
|
cwd: packageDir,
|
|
98
64
|
env: {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
65
|
+
...process.env,
|
|
66
|
+
// Resolve to absolute so prisma.config.ts gets a correct path
|
|
67
|
+
// regardless of this subprocess's cwd (which is packageDir)
|
|
68
|
+
NAISYS_FOLDER: resolve(process.env.NAISYS_FOLDER || ""),
|
|
69
|
+
...envOverrides,
|
|
102
70
|
},
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
75
|
+
if (msg.includes("database is locked")) {
|
|
76
|
+
// Stale WAL/SHM files from a crashed process — remove and retry
|
|
77
|
+
const walPath = absoluteDbPath + "-wal";
|
|
78
|
+
const shmPath = absoluteDbPath + "-shm";
|
|
79
|
+
let removed = false;
|
|
80
|
+
for (const staleFile of [walPath, shmPath]) {
|
|
81
|
+
if (existsSync(staleFile)) {
|
|
82
|
+
console.log(`Removing stale file: ${staleFile}`);
|
|
83
|
+
unlinkSync(staleFile);
|
|
84
|
+
removed = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (removed) {
|
|
88
|
+
console.log("Retrying migration after removing stale WAL files...");
|
|
89
|
+
({ stdout, stderr } = await execAsync(`npx prisma migrate deploy --schema="${schemaPath}"`, {
|
|
90
|
+
cwd: packageDir,
|
|
91
|
+
env: {
|
|
92
|
+
...process.env,
|
|
93
|
+
NAISYS_FOLDER: resolve(process.env.NAISYS_FOLDER || ""),
|
|
94
|
+
...envOverrides,
|
|
95
|
+
},
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
throw new Error(`Database is locked: ${absoluteDbPath}\n` +
|
|
100
|
+
`Another process may be using the database.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (stdout)
|
|
108
|
+
console.log(stdout);
|
|
109
|
+
if (stderr && !stderr.includes("Loaded Prisma config")) {
|
|
110
|
+
console.error(stderr);
|
|
111
|
+
}
|
|
112
|
+
// Upsert schema_version row via raw SQL
|
|
113
|
+
const db = new Database(absoluteDbPath);
|
|
114
|
+
try {
|
|
115
|
+
db.prepare("INSERT OR REPLACE INTO schema_version (id, version, updated) VALUES (1, ?, ?)").run(expectedVersion, new Date().toISOString());
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
db.close();
|
|
113
119
|
}
|
|
114
|
-
|
|
115
|
-
if (stdout) console.log(stdout);
|
|
116
|
-
if (stderr && !stderr.includes("Loaded Prisma config")) {
|
|
117
|
-
console.error(stderr);
|
|
118
|
-
}
|
|
119
|
-
// Upsert schema_version row via raw SQL
|
|
120
|
-
const db = new Database(absoluteDbPath);
|
|
121
|
-
try {
|
|
122
|
-
db.prepare(
|
|
123
|
-
"INSERT OR REPLACE INTO schema_version (id, version, updated) VALUES (1, ?, ?)",
|
|
124
|
-
).run(expectedVersion, new Date().toISOString());
|
|
125
|
-
} finally {
|
|
126
|
-
db.close();
|
|
127
|
-
}
|
|
128
|
-
console.log("Database migration completed.");
|
|
120
|
+
console.log("Database migration completed.");
|
|
129
121
|
}
|
package/dist/sessionCookie.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export const SESSION_COOKIE_NAME = "naisys_session";
|
|
2
2
|
export function sessionCookieOptions(expiresAt) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
return {
|
|
4
|
+
path: "/",
|
|
5
|
+
httpOnly: true,
|
|
6
|
+
sameSite: "lax",
|
|
7
|
+
secure: process.env.NODE_ENV === "production",
|
|
8
|
+
maxAge: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
|
|
9
|
+
};
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naisys/common-node",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "[internal] Node-only utilities for NAISYS",
|
|
6
6
|
"files": [
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"npm:publish": "npm publish --access public"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@naisys/common": "3.0.0-beta.
|
|
19
|
+
"@naisys/common": "3.0.0-beta.7",
|
|
20
20
|
"better-sqlite3": "^12.6.2",
|
|
21
21
|
"js-yaml": "^4.1.1"
|
|
22
22
|
},
|