fuli-context 0.4.0 → 0.5.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.en.md +336 -0
- package/README.md +220 -150
- package/dist/web/assets/KnowledgeOrganizerPage-CZAF_OlN.css +1 -0
- package/dist/web/assets/{KnowledgeOrganizerPage-PXH-TlGt.js → KnowledgeOrganizerPage-DlvcLwlL.js} +1 -1
- package/dist/web/assets/{index-BQRkOwPx.js → index-DAzeFJSc.js} +2 -2
- package/dist/web/index.html +1 -1
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/src/cli/command-registry.js +2 -2
- package/src/cli/local-runtime-command.js +16 -2
- package/src/cli/local-runtime-options.js +6 -2
- package/src/http/request-policy.js +37 -2
- package/src/local-runtime/lifecycle.js +11 -1
- package/src/server/lan-access.js +52 -0
- package/src/server.js +67 -8
- package/src/setup/graph-runtime.js +78 -9
- package/dist/web/assets/KnowledgeOrganizerPage-CJCNI2Wd.css +0 -1
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fuli-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "fuli-context",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.5.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@hono/node-server": "2.0.12",
|
package/package.json
CHANGED
|
@@ -12,9 +12,9 @@ General:
|
|
|
12
12
|
--version, -v
|
|
13
13
|
|
|
14
14
|
Local service:
|
|
15
|
-
start [--port 2727] [--open] [--rebuild] [--data-dir DIR] [--personal-space NAME]
|
|
15
|
+
start [--port 2727] [--open] [--lan] [--rebuild] [--data-dir DIR] [--personal-space NAME]
|
|
16
16
|
stop [--data-dir DIR]
|
|
17
|
-
restart [--port 2727] [--open] [--rebuild] [--data-dir DIR] [--personal-space NAME]
|
|
17
|
+
restart [--port 2727] [--open] [--lan] [--rebuild] [--data-dir DIR] [--personal-space NAME]
|
|
18
18
|
status [--json] [--data-dir DIR]
|
|
19
19
|
open [--data-dir DIR]
|
|
20
20
|
|
|
@@ -40,10 +40,10 @@ export async function runLocalRuntimeCommand(command, args, dependencies = {}) {
|
|
|
40
40
|
export function formatLocalRuntimeResult(command, result) {
|
|
41
41
|
if (command === 'start') {
|
|
42
42
|
const verb = result.status === 'running' ? '已经在运行' : '已启动';
|
|
43
|
-
return
|
|
43
|
+
return formatStartedRuntime(`Fuli 本地服务${verb}。`, result);
|
|
44
44
|
}
|
|
45
45
|
if (command === 'restart') {
|
|
46
|
-
return
|
|
46
|
+
return formatStartedRuntime('Fuli 本地服务已重启。', result);
|
|
47
47
|
}
|
|
48
48
|
if (command === 'stop') {
|
|
49
49
|
if (result.status === 'partial') {
|
|
@@ -61,6 +61,20 @@ export function formatLocalRuntimeResult(command, result) {
|
|
|
61
61
|
throw new TypeError(`Unknown local runtime command: ${command}`);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
function formatStartedRuntime(title, result) {
|
|
65
|
+
const lines = [title, `管理界面:${result.url}`];
|
|
66
|
+
if (result.lan === true) {
|
|
67
|
+
lines.push(
|
|
68
|
+
'局域网界面:',
|
|
69
|
+
...result.lanUrls.map((url) => ` ${url}`),
|
|
70
|
+
`访问用户名:${result.lanAccess.username}`,
|
|
71
|
+
`临时访问口令:${result.lanAccess.accessCode}`,
|
|
72
|
+
'仅在可信 Wi-Fi 中使用;重新启动局域网模式会更换口令。'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
function formatStatus(result) {
|
|
65
79
|
const labels = {
|
|
66
80
|
running: '运行中',
|
|
@@ -9,13 +9,16 @@ const VALUE_OPTIONS = Object.freeze({
|
|
|
9
9
|
const BOOLEAN_OPTIONS = Object.freeze({
|
|
10
10
|
'--open': 'open',
|
|
11
11
|
'--rebuild': 'rebuild',
|
|
12
|
+
'--lan': 'lan',
|
|
12
13
|
'--json': 'json'
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
const ALLOWED_BY_COMMAND = Object.freeze({
|
|
16
|
-
start: new Set(['--data-dir', '--personal-space', '--port', '--open', '--rebuild']),
|
|
17
|
+
start: new Set(['--data-dir', '--personal-space', '--port', '--open', '--rebuild', '--lan']),
|
|
17
18
|
stop: new Set(['--data-dir']),
|
|
18
|
-
restart: new Set([
|
|
19
|
+
restart: new Set([
|
|
20
|
+
'--data-dir', '--personal-space', '--port', '--open', '--rebuild', '--lan'
|
|
21
|
+
]),
|
|
19
22
|
status: new Set(['--data-dir', '--port', '--json']),
|
|
20
23
|
open: new Set(['--data-dir'])
|
|
21
24
|
});
|
|
@@ -29,6 +32,7 @@ export function parseLocalRuntimeOptions(command, args = []) {
|
|
|
29
32
|
port: DEFAULT_FULI_PORT,
|
|
30
33
|
open: false,
|
|
31
34
|
rebuild: false,
|
|
35
|
+
lan: false,
|
|
32
36
|
json: false
|
|
33
37
|
};
|
|
34
38
|
const seen = new Set();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
import { sendJson } from './response.js';
|
|
2
4
|
|
|
3
5
|
export function localServerAuthority(address) {
|
|
@@ -9,18 +11,38 @@ export function localServerAuthority(address) {
|
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export function rejectDisallowedRequest({ request, response, authority }) {
|
|
14
|
+
return rejectRequestOutsidePolicy({ request, response, authority });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function rejectRequestOutsidePolicy({
|
|
18
|
+
request,
|
|
19
|
+
response,
|
|
20
|
+
authority,
|
|
21
|
+
lanAuthorities = [],
|
|
22
|
+
lanAccessToken = null
|
|
23
|
+
}) {
|
|
12
24
|
const origin = request.headers.origin;
|
|
13
25
|
const fetchSite = request.headers['sec-fetch-site'];
|
|
26
|
+
const requestAuthority = request.headers.host;
|
|
27
|
+
const lanRequest = lanAuthorities.includes(requestAuthority);
|
|
28
|
+
const loopbackRequest = requestAuthority === authority;
|
|
14
29
|
if (
|
|
15
30
|
!authority ||
|
|
16
|
-
|
|
17
|
-
(
|
|
31
|
+
(requestAuthority !== authority && !lanRequest) ||
|
|
32
|
+
(lanAuthorities.length > 0 && loopbackRequest && !isLoopbackAddress(request.socket?.remoteAddress)) ||
|
|
33
|
+
(origin !== undefined && origin !== `http://${requestAuthority}`) ||
|
|
18
34
|
(typeof fetchSite === 'string' && fetchSite.toLowerCase() === 'cross-site')
|
|
19
35
|
) {
|
|
20
36
|
sendJson(response, 403, { error: 'Forbidden' });
|
|
21
37
|
return true;
|
|
22
38
|
}
|
|
23
39
|
|
|
40
|
+
if (lanRequest && !hasLanAccess(request.headers.authorization, lanAccessToken)) {
|
|
41
|
+
response.setHeader('www-authenticate', 'Basic realm="FULI LAN", charset="UTF-8"');
|
|
42
|
+
sendJson(response, 401, { error: 'LAN access code required' });
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
if (requiresJson(request) && !isJson(request.headers['content-type'])) {
|
|
25
47
|
sendJson(response, 415, { error: 'Unsupported media type' });
|
|
26
48
|
return true;
|
|
@@ -29,6 +51,19 @@ export function rejectDisallowedRequest({ request, response, authority }) {
|
|
|
29
51
|
return false;
|
|
30
52
|
}
|
|
31
53
|
|
|
54
|
+
function hasLanAccess(authorization, accessToken) {
|
|
55
|
+
if (typeof accessToken !== 'string' || !accessToken) return false;
|
|
56
|
+
const expected = `Basic ${Buffer.from(`fuli:${accessToken}`).toString('base64')}`;
|
|
57
|
+
if (typeof authorization !== 'string' || authorization.length !== expected.length) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return timingSafeEqual(Buffer.from(authorization), Buffer.from(expected));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isLoopbackAddress(address) {
|
|
64
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
function requiresJson(request) {
|
|
33
68
|
if (request.method !== 'POST') return false;
|
|
34
69
|
const pathname = new URL(request.url, 'http://127.0.0.1').pathname;
|
|
@@ -20,6 +20,7 @@ export async function startLocalRuntime(input, dependencies = {}) {
|
|
|
20
20
|
paths: input.paths,
|
|
21
21
|
personalSpaceName: input.personalSpaceName,
|
|
22
22
|
port: input.port,
|
|
23
|
+
lan: input.lan === true,
|
|
23
24
|
personalOnly: !managesDevelopmentWorkspace,
|
|
24
25
|
buildProviders,
|
|
25
26
|
noStart: false,
|
|
@@ -99,7 +100,10 @@ export async function inspectLocalRuntime(input, dependencies = {}) {
|
|
|
99
100
|
console: {
|
|
100
101
|
status: consoleReady ? 'ready' : processAlive ? 'unverified' : 'stopped',
|
|
101
102
|
url: stateLooksOwned ? state.url : `http://127.0.0.1:${input.port}`,
|
|
102
|
-
pid: processAlive ? state.pid : null
|
|
103
|
+
pid: processAlive ? state.pid : null,
|
|
104
|
+
...(stateLooksOwned && state.lan === true
|
|
105
|
+
? { lan: true, lanUrls: safeLanUrls(state.lanUrls) }
|
|
106
|
+
: {})
|
|
103
107
|
},
|
|
104
108
|
personal,
|
|
105
109
|
public: {
|
|
@@ -237,6 +241,12 @@ function isRuntimeState(state) {
|
|
|
237
241
|
Number.isInteger(state.pid) && typeof state.url === 'string';
|
|
238
242
|
}
|
|
239
243
|
|
|
244
|
+
function safeLanUrls(value) {
|
|
245
|
+
return Array.isArray(value)
|
|
246
|
+
? value.filter((url) => typeof url === 'string' && url.startsWith('http://'))
|
|
247
|
+
: [];
|
|
248
|
+
}
|
|
249
|
+
|
|
240
250
|
function isProcessAlive(pid) {
|
|
241
251
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
242
252
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isIPv4 } from 'node:net';
|
|
2
|
+
import { networkInterfaces } from 'node:os';
|
|
3
|
+
|
|
4
|
+
export const LAN_ACCESS_USERNAME = 'fuli';
|
|
5
|
+
|
|
6
|
+
export function discoverLanAddresses(interfaces = networkInterfaces()) {
|
|
7
|
+
const addresses = [];
|
|
8
|
+
for (const entries of Object.values(interfaces ?? {})) {
|
|
9
|
+
for (const entry of entries ?? []) {
|
|
10
|
+
if (!entry || entry.internal || !isIpv4Family(entry.family)) continue;
|
|
11
|
+
if (!isPrivateLanIpv4(entry.address)) continue;
|
|
12
|
+
addresses.push(entry.address);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return [...new Set(addresses)].sort(compareIpv4);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function lanConsoleUrls(addresses, port) {
|
|
19
|
+
return addresses.map((address) => `http://${consoleAuthority(address, port)}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function lanServerAuthorities(addresses, port) {
|
|
23
|
+
return lanConsoleUrls(addresses, port).map((url) => new URL(url).host);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isIpv4Family(family) {
|
|
27
|
+
return family === 'IPv4' || family === 4;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPrivateLanIpv4(address) {
|
|
31
|
+
if (!isIPv4(address)) return false;
|
|
32
|
+
const [first, second] = address.split('.').map(Number);
|
|
33
|
+
return first === 10 ||
|
|
34
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
35
|
+
(first === 192 && second === 168) ||
|
|
36
|
+
(first === 169 && second === 254);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function consoleAuthority(address, port) {
|
|
40
|
+
return Number(port) === 80 ? address : `${address}:${port}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function compareIpv4(left, right) {
|
|
44
|
+
const leftParts = left.split('.').map(Number);
|
|
45
|
+
const rightParts = right.split('.').map(Number);
|
|
46
|
+
for (let index = 0; index < 4; index += 1) {
|
|
47
|
+
if (leftParts[index] !== rightParts[index]) {
|
|
48
|
+
return leftParts[index] - rightParts[index];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
package/src/server.js
CHANGED
|
@@ -4,11 +4,16 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
|
|
5
5
|
import { handleApiRequest } from './http/api-router.js';
|
|
6
6
|
import { mapHttpError } from './http/error-mapping.js';
|
|
7
|
-
import { localServerAuthority,
|
|
7
|
+
import { localServerAuthority, rejectRequestOutsidePolicy } from './http/request-policy.js';
|
|
8
8
|
import { sendJson } from './http/response.js';
|
|
9
9
|
import { serveStatic } from './http/static-handler.js';
|
|
10
10
|
import { DEFAULT_FULI_PORT } from './defaults.js';
|
|
11
11
|
import { resolveGraphRuntimeOptions } from './graphiti/runtime-config.js';
|
|
12
|
+
import {
|
|
13
|
+
discoverLanAddresses,
|
|
14
|
+
lanConsoleUrls,
|
|
15
|
+
lanServerAuthorities
|
|
16
|
+
} from './server/lan-access.js';
|
|
12
17
|
import { createServerApplication } from './server/application-lifecycle.js';
|
|
13
18
|
import { listenServer } from './server/listen.js';
|
|
14
19
|
|
|
@@ -26,8 +31,19 @@ export async function createServer(options = {}) {
|
|
|
26
31
|
store,
|
|
27
32
|
app,
|
|
28
33
|
closeApplicationOnShutdown,
|
|
29
|
-
isBlockedPort = isFetchBlockedPort
|
|
34
|
+
isBlockedPort = isFetchBlockedPort,
|
|
35
|
+
lan = false,
|
|
36
|
+
lanAccessToken = null,
|
|
37
|
+
lanAddresses: configuredLanAddresses = null
|
|
30
38
|
} = options;
|
|
39
|
+
const lanEnabled = lan === true;
|
|
40
|
+
const lanToken = lanEnabled ? requiredLanToken(lanAccessToken) : null;
|
|
41
|
+
const lanAddresses = lanEnabled
|
|
42
|
+
? [...new Set(configuredLanAddresses ?? discoverLanAddresses())]
|
|
43
|
+
: [];
|
|
44
|
+
if (lanEnabled && lanAddresses.length === 0) {
|
|
45
|
+
throw new Error('No private IPv4 LAN address is available');
|
|
46
|
+
}
|
|
31
47
|
if (Number(port) !== 0 && isBlockedPort(port)) {
|
|
32
48
|
throw new Error(`Port ${port} is blocked for local fetch`);
|
|
33
49
|
}
|
|
@@ -45,15 +61,23 @@ export async function createServer(options = {}) {
|
|
|
45
61
|
|
|
46
62
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
47
63
|
let authority = null;
|
|
64
|
+
let allowedLanAuthorities = [];
|
|
48
65
|
const server = createHttpServer((request, response) => {
|
|
49
|
-
handleRequest({
|
|
66
|
+
handleRequest({
|
|
67
|
+
request,
|
|
68
|
+
response,
|
|
69
|
+
app: application,
|
|
70
|
+
authority,
|
|
71
|
+
lanAuthorities: allowedLanAuthorities,
|
|
72
|
+
lanAccessToken: lanToken
|
|
73
|
+
}).catch((error) => {
|
|
50
74
|
const mapped = mapHttpError(error);
|
|
51
75
|
sendJson(response, mapped.status, mapped.body);
|
|
52
76
|
});
|
|
53
77
|
});
|
|
54
78
|
|
|
55
79
|
try {
|
|
56
|
-
await listenServer(server, port);
|
|
80
|
+
await listenServer(server, port, lanEnabled ? '0.0.0.0' : '127.0.0.1');
|
|
57
81
|
} catch (error) {
|
|
58
82
|
runtime.close();
|
|
59
83
|
throw error;
|
|
@@ -66,6 +90,9 @@ export async function createServer(options = {}) {
|
|
|
66
90
|
throw new Error(`Port ${address.port} is blocked for local fetch`);
|
|
67
91
|
}
|
|
68
92
|
authority = localServerAuthority(address);
|
|
93
|
+
allowedLanAuthorities = lanEnabled
|
|
94
|
+
? lanServerAuthorities(lanAddresses, address.port)
|
|
95
|
+
: [];
|
|
69
96
|
if (!authority) {
|
|
70
97
|
await new Promise((resolveClose) => server.close(resolveClose));
|
|
71
98
|
runtime.close();
|
|
@@ -73,7 +100,12 @@ export async function createServer(options = {}) {
|
|
|
73
100
|
}
|
|
74
101
|
|
|
75
102
|
server.once('close', runtime.close);
|
|
76
|
-
return {
|
|
103
|
+
return {
|
|
104
|
+
server,
|
|
105
|
+
url: `http://${authority}`,
|
|
106
|
+
lanUrls: lanEnabled ? lanConsoleUrls(lanAddresses, address.port) : [],
|
|
107
|
+
app: application
|
|
108
|
+
};
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
runtime.close();
|
|
@@ -94,8 +126,21 @@ export function isFetchBlockedPort(port) {
|
|
|
94
126
|
return FETCH_BLOCKED_PORTS.has(Number(port));
|
|
95
127
|
}
|
|
96
128
|
|
|
97
|
-
async function handleRequest({
|
|
98
|
-
|
|
129
|
+
async function handleRequest({
|
|
130
|
+
request,
|
|
131
|
+
response,
|
|
132
|
+
app,
|
|
133
|
+
authority,
|
|
134
|
+
lanAuthorities,
|
|
135
|
+
lanAccessToken
|
|
136
|
+
}) {
|
|
137
|
+
if (rejectRequestOutsidePolicy({
|
|
138
|
+
request,
|
|
139
|
+
response,
|
|
140
|
+
authority,
|
|
141
|
+
lanAuthorities,
|
|
142
|
+
lanAccessToken
|
|
143
|
+
})) return;
|
|
99
144
|
if (await handleApiRequest({ request, response, app })) return;
|
|
100
145
|
serveStatic(new URL(request.url, 'http://127.0.0.1').pathname, response);
|
|
101
146
|
}
|
|
@@ -110,7 +155,13 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
|
110
155
|
async function runProgram(args) {
|
|
111
156
|
const runtimeOptions = resolveGraphRuntimeOptions(args, process.env);
|
|
112
157
|
const port = numberOption(args, '--port', DEFAULT_FULI_PORT);
|
|
113
|
-
const
|
|
158
|
+
const lan = args.includes('--lan');
|
|
159
|
+
const { server, url, lanUrls } = await createServer({
|
|
160
|
+
...runtimeOptions,
|
|
161
|
+
port,
|
|
162
|
+
lan,
|
|
163
|
+
lanAccessToken: lan ? process.env.FULI_LAN_ACCESS_TOKEN : null
|
|
164
|
+
});
|
|
114
165
|
const handlers = new Map();
|
|
115
166
|
const close = () => server.close();
|
|
116
167
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
@@ -122,6 +173,14 @@ async function runProgram(args) {
|
|
|
122
173
|
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
123
174
|
});
|
|
124
175
|
console.log(`复利工作台 running at ${url}`);
|
|
176
|
+
for (const lanUrl of lanUrls) console.log(`复利工作台 LAN at ${lanUrl}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function requiredLanToken(value) {
|
|
180
|
+
if (typeof value !== 'string' || value.length < 16) {
|
|
181
|
+
throw new TypeError('LAN mode requires a generated access token');
|
|
182
|
+
}
|
|
183
|
+
return value;
|
|
125
184
|
}
|
|
126
185
|
|
|
127
186
|
function numberOption(args, flag, fallback) {
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
ensureContainerRuntime,
|
|
16
16
|
runDockerCompose
|
|
17
17
|
} from './container-runtime.js';
|
|
18
|
+
import {
|
|
19
|
+
discoverLanAddresses,
|
|
20
|
+
LAN_ACCESS_USERNAME,
|
|
21
|
+
lanConsoleUrls
|
|
22
|
+
} from '../server/lan-access.js';
|
|
18
23
|
import { readJsonFile, writeJsonFileAtomic } from '../storage/json-file.js';
|
|
19
24
|
|
|
20
25
|
export {
|
|
@@ -30,6 +35,11 @@ export const MANAGED_WORKSPACE_PROVIDER_URL = WORKSPACE_PROVIDER_URL;
|
|
|
30
35
|
|
|
31
36
|
export async function ensureGraphRuntime(input, dependencies = {}) {
|
|
32
37
|
const deps = graphDependencies(dependencies);
|
|
38
|
+
const lan = input.lan === true;
|
|
39
|
+
const lanAddresses = lan ? deps.discoverLanAddresses() : [];
|
|
40
|
+
if (lan && lanAddresses.length === 0) {
|
|
41
|
+
throw new Error('未发现可用的私有 IPv4 局域网地址,无法启动 LAN 模式。');
|
|
42
|
+
}
|
|
33
43
|
const containerRuntime = await deps.ensureContainerRuntime({
|
|
34
44
|
env: input.env ?? process.env,
|
|
35
45
|
onProgress: input.onProgress
|
|
@@ -61,33 +71,52 @@ export async function ensureGraphRuntime(input, dependencies = {}) {
|
|
|
61
71
|
const existing = deps.readState(input.paths.graphRuntimeStatePath);
|
|
62
72
|
if (isGraphRuntimeState(existing) && deps.isProcessAlive(existing.pid)) {
|
|
63
73
|
const healthy = await deps.webHealth(existing.url, existing.pid, existing.version);
|
|
64
|
-
if (
|
|
65
|
-
|
|
74
|
+
if (
|
|
75
|
+
healthy &&
|
|
76
|
+
existing.port === input.port &&
|
|
77
|
+
exposureMatches(existing, { lan, lanAddresses })
|
|
78
|
+
) {
|
|
79
|
+
return runtimeResult('running', existing);
|
|
66
80
|
}
|
|
67
81
|
if (!healthy) throw new Error('Recorded Fuli Graphiti runtime is not healthy');
|
|
68
82
|
deps.stopProcess(existing.pid);
|
|
83
|
+
if (!await deps.waitForExit(existing.pid)) {
|
|
84
|
+
throw new Error('Fuli Graphiti console did not stop before changing exposure mode');
|
|
85
|
+
}
|
|
69
86
|
}
|
|
87
|
+
const lanAccessToken = lan ? deps.createLanAccessToken() : null;
|
|
70
88
|
const child = deps.spawnWebRuntime({
|
|
71
89
|
paths: input.paths,
|
|
72
90
|
nodePath: process.execPath,
|
|
73
|
-
port: input.port
|
|
91
|
+
port: input.port,
|
|
92
|
+
lan,
|
|
93
|
+
lanAccessToken,
|
|
94
|
+
env: input.env ?? process.env
|
|
74
95
|
});
|
|
75
96
|
const url = `http://127.0.0.1:${input.port}`;
|
|
76
97
|
if (!Number.isInteger(child.pid) || !await waitForWeb(url, child.pid, deps)) {
|
|
77
98
|
if (Number.isInteger(child.pid)) deps.stopProcess(child.pid);
|
|
78
99
|
throw new Error('Fuli Graphiti console could not start');
|
|
79
100
|
}
|
|
80
|
-
|
|
101
|
+
const state = {
|
|
81
102
|
version: 3,
|
|
82
103
|
pid: child.pid,
|
|
83
104
|
url,
|
|
84
105
|
port: input.port,
|
|
106
|
+
...(lan
|
|
107
|
+
? {
|
|
108
|
+
lan: true,
|
|
109
|
+
lanUrls: lanConsoleUrls(lanAddresses, input.port)
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
85
112
|
managedProviders: input.personalOnly
|
|
86
113
|
? ['personal']
|
|
87
114
|
: ['personal', 'development-workspace'],
|
|
88
115
|
startedAt: new Date().toISOString()
|
|
89
|
-
}
|
|
90
|
-
|
|
116
|
+
};
|
|
117
|
+
deps.writeState(input.paths.graphRuntimeStatePath, state);
|
|
118
|
+
deps.secureFile(input.paths.graphRuntimeStatePath);
|
|
119
|
+
return runtimeResult('started', state, lanAccessToken);
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
function ensureProviderSecrets(path, deps) {
|
|
@@ -250,14 +279,19 @@ function spawnWebRuntime(input) {
|
|
|
250
279
|
mkdirSync(dirname(input.paths.logPath), { recursive: true });
|
|
251
280
|
const log = openSync(input.paths.logPath, 'a');
|
|
252
281
|
try {
|
|
253
|
-
const
|
|
282
|
+
const args = [
|
|
254
283
|
input.paths.serverPath,
|
|
255
284
|
'--runtime-config', input.paths.graphRuntimeConfigPath,
|
|
256
285
|
'--port', String(input.port)
|
|
257
|
-
]
|
|
286
|
+
];
|
|
287
|
+
if (input.lan) args.push('--lan');
|
|
288
|
+
const child = spawn(input.nodePath, args, {
|
|
258
289
|
detached: true,
|
|
259
290
|
windowsHide: true,
|
|
260
|
-
stdio: ['ignore', log, log]
|
|
291
|
+
stdio: ['ignore', log, log],
|
|
292
|
+
env: input.lan
|
|
293
|
+
? { ...input.env, FULI_LAN_ACCESS_TOKEN: input.lanAccessToken }
|
|
294
|
+
: input.env
|
|
261
295
|
});
|
|
262
296
|
child.unref();
|
|
263
297
|
return child;
|
|
@@ -292,6 +326,9 @@ function graphDependencies(overrides) {
|
|
|
292
326
|
stopProcess,
|
|
293
327
|
stopLegacyService,
|
|
294
328
|
webHealth: checkLocalConsoleHealth,
|
|
329
|
+
discoverLanAddresses,
|
|
330
|
+
createLanAccessToken: secret,
|
|
331
|
+
waitForExit: waitForProcessExit,
|
|
295
332
|
...overrides
|
|
296
333
|
};
|
|
297
334
|
}
|
|
@@ -323,6 +360,30 @@ function isGraphRuntimeState(state) {
|
|
|
323
360
|
Number.isInteger(state.pid) && typeof state.url === 'string';
|
|
324
361
|
}
|
|
325
362
|
|
|
363
|
+
function exposureMatches(state, { lan }) {
|
|
364
|
+
if ((state.lan === true) !== lan) return false;
|
|
365
|
+
if (!lan) return true;
|
|
366
|
+
// A repeated explicit LAN start rotates the in-memory access code.
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function runtimeResult(status, state, lanAccessToken = null) {
|
|
371
|
+
const result = { status, url: state.url, pid: state.pid };
|
|
372
|
+
if (state.lan !== true) return result;
|
|
373
|
+
if (typeof lanAccessToken !== 'string' || lanAccessToken.length < 16) {
|
|
374
|
+
throw new Error('LAN runtime started without a valid access code');
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
...result,
|
|
378
|
+
lan: true,
|
|
379
|
+
lanUrls: [...state.lanUrls],
|
|
380
|
+
lanAccess: {
|
|
381
|
+
username: LAN_ACCESS_USERNAME,
|
|
382
|
+
accessCode: lanAccessToken
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
326
387
|
function stopLegacyService() {
|
|
327
388
|
if (process.platform !== 'darwin') return;
|
|
328
389
|
spawnSync('launchctl', ['remove', 'dev.jevonscode.fuli.local'], {
|
|
@@ -355,6 +416,14 @@ function stopProcess(pid) {
|
|
|
355
416
|
try { process.kill(pid, 'SIGTERM'); } catch { /* already stopped */ }
|
|
356
417
|
}
|
|
357
418
|
|
|
419
|
+
async function waitForProcessExit(pid) {
|
|
420
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
421
|
+
if (!isProcessAlive(pid)) return true;
|
|
422
|
+
await delay(100);
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
358
427
|
function delay(ms) {
|
|
359
428
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
360
429
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
.batch-confirm-dialog[data-v-5b6ea24b]{width:min(980px,100vw - 64px)}.batch-confirm-form[data-v-5b6ea24b]{grid-template-columns:320px minmax(0,1fr);gap:24px;padding-top:20px;display:grid}.batch-confirm-controls[data-v-5b6ea24b]{align-content:start;gap:14px;display:grid}.batch-confirm-controls>label[data-v-5b6ea24b],.batch-confirmer-fields label[data-v-5b6ea24b]{color:#5f6962;gap:6px;font-size:10px;font-weight:600;display:grid}.batch-confirm-controls[data-v-5b6ea24b] .searchable-select{width:100%}.batch-group-summary[data-v-5b6ea24b]{border-top:1px solid #e1e5e2;border-bottom:1px solid #e1e5e2;gap:4px;padding:12px 0;display:grid}.batch-group-summary span[data-v-5b6ea24b],.batch-group-summary small[data-v-5b6ea24b]{color:#89918b;font-size:8px}.batch-group-summary strong[data-v-5b6ea24b]{color:#35443b;font-size:12px}.batch-group-summary p[data-v-5b6ea24b]{color:#6e7871;font-size:9px;line-height:1.5}.batch-confirmer-fields[data-v-5b6ea24b]{grid-template-columns:112px minmax(0,1fr);gap:8px;display:grid}.batch-confirm-rule[data-v-5b6ea24b],.batch-limit-note[data-v-5b6ea24b]{color:#7c857f;font-size:9px;line-height:1.5}.batch-confirm-review[data-v-5b6ea24b]{border-left:1px solid #e1e5e2;min-width:0;padding-left:24px}.batch-review-heading[data-v-5b6ea24b]{justify-content:space-between;align-items:center;gap:16px;padding-bottom:10px;display:flex}.batch-review-heading h4[data-v-5b6ea24b]{color:#344239;font-size:13px}.batch-review-heading p[data-v-5b6ea24b]{color:#858e87;margin-top:2px;font-size:9px}.batch-review-list[data-v-5b6ea24b]{border-top:1px solid #e3e6e3;border-bottom:1px solid #e3e6e3;max-height:410px;overflow:auto}.batch-review-item[data-v-5b6ea24b]{cursor:pointer;border-bottom:1px solid #eceeec;grid-template-columns:auto minmax(0,1fr);gap:9px;padding:10px 2px;display:grid}.batch-review-item[data-v-5b6ea24b]:last-child{border-bottom:0}.batch-review-item>input[data-v-5b6ea24b]{margin-top:2px}.batch-review-item>span[data-v-5b6ea24b]{gap:3px;min-width:0;display:grid}.batch-review-item strong[data-v-5b6ea24b]{color:#3a4740;font-size:10px}.batch-review-item small[data-v-5b6ea24b]{color:#77817a;font-size:8px}.batch-review-item em[data-v-5b6ea24b]{color:#848d87;text-overflow:ellipsis;white-space:nowrap;font-size:8px;font-style:normal;line-height:1.4;overflow:hidden}.batch-confirm-acknowledgement[data-v-5b6ea24b]{color:#4f5d55;cursor:pointer;align-items:flex-start;gap:8px;margin-top:12px;font-size:9px;line-height:1.5;display:flex}.batch-confirm-form>.publish-dialog-error[data-v-5b6ea24b],.batch-confirm-form>.publish-dialog-actions[data-v-5b6ea24b]{grid-column:1/-1}@media (width<=820px){.batch-confirm-form[data-v-5b6ea24b]{grid-template-columns:1fr}.batch-confirm-review[data-v-5b6ea24b]{border-left:0;padding:0}}.knowledge-organizer[data-v-e7137842]{flex-direction:column;gap:12px;padding:18px 24px 20px;display:flex;overflow:hidden}.organizer-head[data-v-e7137842]{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px 24px;min-width:0;display:grid}.organizer-principle[data-v-e7137842]{align-self:center;min-width:0;padding:0 2px}.organizer-principle h3[data-v-e7137842]{font-size:16px}.organizer-principle>p[data-v-e7137842]:last-child{color:#6e7771;max-width:760px;margin-top:5px;font-size:11px;line-height:1.55}.organizer-attention[data-v-e7137842]{text-align:right;border-left:1px solid #dde2de;grid-area:1/2;min-width:152px;padding-left:20px}.organizer-attention strong[data-v-e7137842],.organizer-attention span[data-v-e7137842],.organizer-attention small[data-v-e7137842]{display:block}.organizer-attention strong[data-v-e7137842]{color:#6f582b;font-size:23px;font-weight:620}.organizer-attention span[data-v-e7137842]{color:#5f675f;font-size:10px;font-weight:650}.organizer-attention small[data-v-e7137842]{color:#8a918b;margin-top:2px;font-size:8px}.organizer-loading-inline[data-v-e7137842]{color:#7b847e;text-align:center;font-size:10px}.organizer-toolbar[data-v-e7137842]{align-items:center;gap:8px;min-height:34px;display:flex}.organizer-search[data-v-e7137842]{flex:300px;min-width:180px;max-width:360px}.organizer-search>span[data-v-e7137842]{clip:rect(0 0 0 0);width:1px;height:1px;position:absolute;overflow:hidden}.organizer-search input[data-v-e7137842]{width:100%}.review-state-filter[data-v-e7137842],.quadrant-filter[data-v-e7137842]{white-space:nowrap;background:#f1f4f1;border:1px solid #d7ddd8;border-radius:7px;flex:none;align-items:center;gap:2px;height:31px;padding:2px;display:flex}.quadrant-filter[data-v-e7137842]{max-width:470px;overflow-x:auto}.review-state-filter>span[data-v-e7137842],.quadrant-filter>span[data-v-e7137842]{color:#7a847d;padding:0 6px;font-size:8px;font-weight:650}.review-state-filter button[data-v-e7137842],.quadrant-filter button[data-v-e7137842]{color:#626d66;white-space:nowrap;background:0 0;border:0;border-radius:5px;height:25px;padding:0 7px;font-size:9px}.review-state-filter button[data-v-e7137842]:hover,.quadrant-filter button[data-v-e7137842]:hover{color:#35473d;background:#ffffffa8}.review-state-filter button.active[data-v-e7137842],.quadrant-filter button.active[data-v-e7137842]{color:#30483a;background:#fff;box-shadow:0 1px 3px #2f3d341f}.review-state-filter button.state-pending.active[data-v-e7137842]{color:#765d2e}.review-state-filter button.state-agent_confirmed.active[data-v-e7137842]{color:#4d5f78}.review-state-filter button.state-confirmed.active[data-v-e7137842]{color:#356047}.review-state-filter button strong[data-v-e7137842],.quadrant-filter button strong[data-v-e7137842]{margin-left:2px;font-size:8px;font-weight:720}.organizer-result-summary[data-v-e7137842]{color:#7b847e;white-space:nowrap;flex:none;font-size:9px}.organizer-toolbar .batch-confirm-action[data-v-e7137842]{margin-left:auto}.organizer-toolbar .toolbar-action+.toolbar-action[data-v-e7137842]{margin-left:0}.organizer-truncated[data-v-e7137842]{color:#806536;margin:-4px 0 0;font-size:9px}.organizer-layout[data-v-e7137842]{background:#fff;border:1px solid #cfd7d1;border-radius:8px;flex:1;grid-template-columns:minmax(0,1fr);min-height:0;display:grid;overflow:hidden}.organizer-layout.has-selection[data-v-e7137842]{grid-template-columns:minmax(0,1fr) 340px}.organizer-layout[data-v-e7137842]:not(.has-selection) .graph-inspector{display:none}.organizer-directory[data-v-e7137842]{overscroll-behavior:contain;min-width:0;overflow:auto}.organizer-table-head[data-v-e7137842],.organizer-row[data-v-e7137842]{grid-template-columns:minmax(230px,1.35fr) 114px 130px minmax(160px,.8fr) 118px;align-items:center;gap:12px;min-width:920px;display:grid}.organizer-table-head[data-v-e7137842]{z-index:2;color:#8b938d;background:#f7f8f6;border-bottom:1px solid #e0e4e0;padding:10px 15px;font-size:9px;font-weight:680;position:sticky;top:0}.organizer-row[data-v-e7137842]{color:#4f5952;text-align:left;background:#fff;border:0;border-bottom:1px solid #e8ebe8;width:100%;min-height:68px;padding:11px 15px}.organizer-row[data-v-e7137842]:hover{background:#f8faf8}.organizer-row.selected[data-v-e7137842]{background:#f1f6f3;box-shadow:inset 2px 0 #557863}.organizer-item-copy[data-v-e7137842]{gap:4px;min-width:0;display:grid}.organizer-item-copy strong[data-v-e7137842],.organizer-item-copy small[data-v-e7137842]{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.organizer-item-copy strong[data-v-e7137842]{color:#2f3933;font-size:11px;font-weight:630}.organizer-item-copy small[data-v-e7137842],.organizer-basis[data-v-e7137842],.organizer-row time[data-v-e7137842]{color:#7b847e;font-size:9px}.quadrant-chip[data-v-e7137842],.review-chip[data-v-e7137842]{white-space:nowrap;border-radius:999px;width:fit-content;padding:4px 7px;font-size:9px;font-weight:650}.quadrant-chip[data-v-e7137842]{color:#59655e;background:#f0f2ef;gap:1px;display:grid}.quadrant-chip small[data-v-e7137842]{color:inherit;opacity:.75;font-size:7px;font-weight:520}.quadrant-chip.known_known[data-v-e7137842]{color:#356047;background:#edf6f0}.quadrant-chip.known_unknown[data-v-e7137842]{color:#77602f;background:#f7f2e5}.quadrant-chip.unknown_known[data-v-e7137842]{color:#4d5f78;background:#eef2f7}.quadrant-chip.unknown_unknown[data-v-e7137842]{color:#725d70;background:#f5eff4}.review-chip.state-confirmed[data-v-e7137842]{color:#356047;background:#edf6f0}.review-chip.state-agent_confirmed[data-v-e7137842]{color:#4d5f78;background:#eef2f7}.review-chip.state-pending[data-v-e7137842]{color:#77602f;background:#f7f2e5}.organizer-basis[data-v-e7137842]{overflow-wrap:anywhere}@media (width<=1260px){.review-state-filter>span[data-v-e7137842],.quadrant-filter>span[data-v-e7137842],.organizer-result-summary[data-v-e7137842]{display:none}.organizer-table-head[data-v-e7137842],.organizer-row[data-v-e7137842]{grid-template-columns:minmax(210px,1fr) 108px 104px minmax(140px,.8fr) 104px;min-width:760px}}@media (width<=1040px){.organizer-layout.has-selection[data-v-e7137842]{grid-template-columns:minmax(0,1fr) 280px}.organizer-table-head[data-v-e7137842],.organizer-row[data-v-e7137842]{grid-template-columns:minmax(210px,1fr) 108px 104px;min-width:470px}.organizer-table-head[data-v-e7137842]>:nth-child(n+4),.organizer-basis[data-v-e7137842],.organizer-row time[data-v-e7137842]{display:none}.batch-confirm-action[data-v-e7137842]{text-overflow:ellipsis;white-space:nowrap;max-width:108px;overflow:hidden}}
|