@aipoch/open-science 0.0.0-stage → 1.0.2
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/CLI.md +814 -0
- package/LICENSE +201 -0
- package/README.md +284 -2
- package/appimage-launcher.mjs +90 -0
- package/browser-import.d.ts +66 -0
- package/browser-import.mjs +389 -0
- package/cli.mjs +1845 -0
- package/codex-login.mjs +298 -0
- package/config-root.mjs +65 -0
- package/index.d.ts +677 -0
- package/index.mjs +761 -0
- package/installation-startup.cjs +205 -0
- package/locate-app.mjs +95 -0
- package/locate-backend.mjs +63 -0
- package/package.json +45 -4
- package/runtime-control.mjs +51 -0
- package/runtime-package.mjs +19 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
|
|
2
|
+
// Keep this bootstrap free of application/native addon imports. It runs before spawning the backend.
|
|
3
|
+
const { createHash, randomUUID } = require('node:crypto')
|
|
4
|
+
const {
|
|
5
|
+
mkdirSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
rmSync
|
|
12
|
+
} = require('node:fs')
|
|
13
|
+
const { dirname, join, resolve } = require('node:path')
|
|
14
|
+
const { homedir } = require('node:os')
|
|
15
|
+
|
|
16
|
+
function installationRootForEntry(entryPath) {
|
|
17
|
+
// AppImage mount directories change on every launch; coordinate the stable outer image.
|
|
18
|
+
if (
|
|
19
|
+
process.platform === 'linux' &&
|
|
20
|
+
process.env.APPIMAGE &&
|
|
21
|
+
process.env.APPDIR &&
|
|
22
|
+
resolve(entryPath).startsWith(resolve(process.env.APPDIR) + '/')
|
|
23
|
+
)
|
|
24
|
+
return existsSync(process.env.APPIMAGE)
|
|
25
|
+
? realpathSync(process.env.APPIMAGE)
|
|
26
|
+
: resolve(process.env.APPIMAGE)
|
|
27
|
+
let existing = resolve(entryPath),
|
|
28
|
+
suffix = ''
|
|
29
|
+
while (!existsSync(existing)) {
|
|
30
|
+
suffix = join(existing.slice(dirname(existing).length + 1), suffix)
|
|
31
|
+
existing = dirname(existing)
|
|
32
|
+
}
|
|
33
|
+
const entry = resolve(realpathSync(existing), suffix)
|
|
34
|
+
let cursor = dirname(entry)
|
|
35
|
+
while (dirname(cursor) !== cursor) {
|
|
36
|
+
if (cursor.endsWith(join('Contents', 'Resources'))) return dirname(dirname(cursor))
|
|
37
|
+
if (cursor.endsWith('/resources') || cursor.endsWith('\\resources')) return dirname(cursor)
|
|
38
|
+
cursor = dirname(cursor)
|
|
39
|
+
}
|
|
40
|
+
const backend = dirname(entry)
|
|
41
|
+
return dirname(backend).endsWith('/out') || dirname(backend).endsWith('\\out')
|
|
42
|
+
? dirname(dirname(backend))
|
|
43
|
+
: dirname(backend)
|
|
44
|
+
}
|
|
45
|
+
const REGISTRATION_NAME = /^([\da-f-]{36})\.(json|sqlite)$/
|
|
46
|
+
const MAX_REAPED_PER_PASS = 200
|
|
47
|
+
// Caller MUST hold the coordinator lock: registrations are only created, probed and reclaimed under
|
|
48
|
+
// it, so a free kernel lock here means the owner is gone and nobody can re-open the file meanwhile.
|
|
49
|
+
// The pair is removed json first, so a reader never sees a registration without its lifetime lock.
|
|
50
|
+
function reapStaleRegistrations(directory, DatabaseSync = require('node:sqlite').DatabaseSync) {
|
|
51
|
+
const ids = new Set()
|
|
52
|
+
for (const name of readdirSync(directory)) {
|
|
53
|
+
const match = REGISTRATION_NAME.exec(name)
|
|
54
|
+
if (match) ids.add(match[1])
|
|
55
|
+
}
|
|
56
|
+
let reaped = 0
|
|
57
|
+
for (const id of ids) {
|
|
58
|
+
if (reaped >= MAX_REAPED_PER_PASS) break
|
|
59
|
+
const lock = join(directory, `${id}.sqlite`)
|
|
60
|
+
let probe
|
|
61
|
+
try {
|
|
62
|
+
probe = new DatabaseSync(lock)
|
|
63
|
+
probe.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE')
|
|
64
|
+
} catch {
|
|
65
|
+
// Held by a live process (or unreadable right now): never delete it.
|
|
66
|
+
try {
|
|
67
|
+
probe?.close()
|
|
68
|
+
} catch {
|
|
69
|
+
// Already closed or never opened.
|
|
70
|
+
}
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
// Close before unlinking: Windows cannot remove an open file.
|
|
74
|
+
probe.close()
|
|
75
|
+
try {
|
|
76
|
+
rmSync(join(directory, `${id}.json`), { force: true })
|
|
77
|
+
rmSync(lock, { force: true })
|
|
78
|
+
reaped += 1
|
|
79
|
+
} catch {
|
|
80
|
+
// Best effort: the next pass retries.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return reaped
|
|
84
|
+
}
|
|
85
|
+
function protectInstallationStartup(entryPath, configRoot, stateRoot) {
|
|
86
|
+
// Loading node:sqlite emits an experimental warning on Node 22; ordinary CLI commands
|
|
87
|
+
// must not load it and contaminate their machine-readable output.
|
|
88
|
+
let DatabaseSync
|
|
89
|
+
try {
|
|
90
|
+
;({ DatabaseSync } = require('node:sqlite'))
|
|
91
|
+
} catch (cause) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Open-Science startup requires Node.js 22.13 or newer (node:sqlite is unavailable on ${process.version}).`,
|
|
94
|
+
{ cause }
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
const root = installationRootForEntry(entryPath)
|
|
98
|
+
const key = createHash('sha256')
|
|
99
|
+
.update(process.platform === 'win32' ? root.toLowerCase() : root)
|
|
100
|
+
.digest('hex')
|
|
101
|
+
const directory = join(
|
|
102
|
+
stateRoot ??
|
|
103
|
+
process.env.OPEN_SCIENCE_INSTALLATION_STATE_ROOT ??
|
|
104
|
+
(process.env.OPEN_SCIENCE_E2E_STORAGE_ROOT
|
|
105
|
+
? join(process.env.OPEN_SCIENCE_E2E_STORAGE_ROOT, 'installations')
|
|
106
|
+
: join(homedir(), '.open-science', 'installations')),
|
|
107
|
+
key
|
|
108
|
+
)
|
|
109
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
110
|
+
const coordinator = new DatabaseSync(join(directory, 'coordinator.sqlite'))
|
|
111
|
+
let lease
|
|
112
|
+
let identity
|
|
113
|
+
try {
|
|
114
|
+
coordinator.exec('PRAGMA busy_timeout = 1500; PRAGMA journal_mode = MEMORY; BEGIN EXCLUSIVE')
|
|
115
|
+
if (existsSync(join(directory, 'update-in-progress.json')))
|
|
116
|
+
throw new Error('Installation update requires recovery before starting Open-Science.')
|
|
117
|
+
reapStaleRegistrations(directory, DatabaseSync)
|
|
118
|
+
let owner
|
|
119
|
+
const helperFlags = [
|
|
120
|
+
'--open-science-artifact-mcp',
|
|
121
|
+
'--open-science-notebook-mcp',
|
|
122
|
+
'--open-science-reviewer-mcp-proxy',
|
|
123
|
+
'--open-science-skill-import-mcp',
|
|
124
|
+
'--open-science-skill-runtime-mcp',
|
|
125
|
+
'--open-science-plan-mcp'
|
|
126
|
+
]
|
|
127
|
+
if (
|
|
128
|
+
helperFlags.some((flag) => process.argv.includes(flag)) &&
|
|
129
|
+
process.env.OPEN_SCIENCE_INSTALLATION_OWNER
|
|
130
|
+
) {
|
|
131
|
+
const candidate = JSON.parse(process.env.OPEN_SCIENCE_INSTALLATION_OWNER)
|
|
132
|
+
const tokenHash = createHash('sha256').update(String(candidate.token)).digest('hex')
|
|
133
|
+
for (const name of readdirSync(directory).filter((name) =>
|
|
134
|
+
/^[\da-f-]{36}\.json$/.test(name)
|
|
135
|
+
)) {
|
|
136
|
+
let registration
|
|
137
|
+
try {
|
|
138
|
+
registration = JSON.parse(readFileSync(join(directory, name), 'utf8'))
|
|
139
|
+
} catch {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
registration.pid !== candidate.pid ||
|
|
144
|
+
registration.generation !== candidate.generation ||
|
|
145
|
+
registration.configRoot !== candidate.configRoot ||
|
|
146
|
+
registration.helperTokenHash !== tokenHash
|
|
147
|
+
)
|
|
148
|
+
continue
|
|
149
|
+
const probe = new DatabaseSync(join(directory, name.replace(/\.json$/, '.sqlite')))
|
|
150
|
+
try {
|
|
151
|
+
probe.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE')
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error.errcode === 5 || error.errcode === 6)
|
|
154
|
+
owner = {
|
|
155
|
+
pid: candidate.pid,
|
|
156
|
+
generation: candidate.generation,
|
|
157
|
+
configRoot: candidate.configRoot
|
|
158
|
+
}
|
|
159
|
+
else throw error
|
|
160
|
+
} finally {
|
|
161
|
+
probe.close()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!owner) throw new Error('The runtime helper installation capability is no longer valid.')
|
|
165
|
+
configRoot = owner.configRoot
|
|
166
|
+
}
|
|
167
|
+
const id = randomUUID()
|
|
168
|
+
identity = Object.freeze({
|
|
169
|
+
pid: process.pid,
|
|
170
|
+
generation: id,
|
|
171
|
+
configRoot,
|
|
172
|
+
installationRoot: root
|
|
173
|
+
})
|
|
174
|
+
lease = new DatabaseSync(join(directory, `${id}.sqlite`))
|
|
175
|
+
lease.exec('PRAGMA busy_timeout = 1500; PRAGMA journal_mode = MEMORY; BEGIN EXCLUSIVE')
|
|
176
|
+
writeFileSync(
|
|
177
|
+
join(directory, `${id}.json`),
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
pid: process.pid,
|
|
180
|
+
generation: id,
|
|
181
|
+
configRoot,
|
|
182
|
+
...(owner ? { role: 'helper', owner } : {})
|
|
183
|
+
}),
|
|
184
|
+
{ flag: 'wx', mode: 0o600 }
|
|
185
|
+
)
|
|
186
|
+
} catch (error) {
|
|
187
|
+
lease?.close()
|
|
188
|
+
throw error
|
|
189
|
+
} finally {
|
|
190
|
+
coordinator.close()
|
|
191
|
+
}
|
|
192
|
+
let released = false
|
|
193
|
+
return {
|
|
194
|
+
identity,
|
|
195
|
+
isHeld: () => !released,
|
|
196
|
+
release() {
|
|
197
|
+
if (!released) {
|
|
198
|
+
released = true
|
|
199
|
+
lease.close()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { installationRootForEntry, protectInstallationStartup, reapStaleRegistrations }
|
package/locate-app.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
2
|
+
|
|
3
|
+
import { access, readFile, realpath } from 'node:fs/promises'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { dirname, join, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const cliDir = dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const repositoryRoot = resolve(cliDir, '../..')
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
|
|
12
|
+
const exists = async (path) => {
|
|
13
|
+
try {
|
|
14
|
+
await access(path)
|
|
15
|
+
return true
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isCurrentCli = async (candidate) => {
|
|
22
|
+
if (!process.argv[1]) return false
|
|
23
|
+
try {
|
|
24
|
+
return (await realpath(candidate)) === (await realpath(process.argv[1]))
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Standalone discovery follows current packaging defaults. Legacy/custom/mixed layouts require
|
|
31
|
+
// an explicit binding; public Linux commands may be CLI wrappers and are never desktop candidates.
|
|
32
|
+
const defaultInstalledCandidates = (env = process.env) => {
|
|
33
|
+
if (process.platform === 'win32') {
|
|
34
|
+
return [env.LOCALAPPDATA && join(env.LOCALAPPDATA, 'Programs'), env.PROGRAMFILES]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((root) => join(root, 'Open-Science', 'open-science.exe'))
|
|
37
|
+
}
|
|
38
|
+
if (process.platform === 'darwin') {
|
|
39
|
+
return ['/Applications', env.HOME && join(env.HOME, 'Applications')]
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.map((root) => join(root, 'Open-Science.app', 'Contents', 'MacOS', 'Open-Science'))
|
|
42
|
+
}
|
|
43
|
+
return ['/opt/Open-Science/open-science']
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const locateDevelopmentApp = async () => {
|
|
47
|
+
const mainEntry = join(repositoryRoot, 'out', 'main', 'index.js')
|
|
48
|
+
const webEntry = join(repositoryRoot, 'out', 'web', 'index.html')
|
|
49
|
+
const electronModule = join(repositoryRoot, 'node_modules', 'electron')
|
|
50
|
+
if (!(await exists(mainEntry)) || !(await exists(webEntry)) || !(await exists(electronModule))) {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
const electronPath = require(electronModule)
|
|
54
|
+
if (!(await exists(electronPath))) return undefined
|
|
55
|
+
return {
|
|
56
|
+
command: electronPath,
|
|
57
|
+
args: [repositoryRoot],
|
|
58
|
+
packaged: false,
|
|
59
|
+
repositoryRoot
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const locateApp = async ({ appPath, env = process.env } = {}) => {
|
|
64
|
+
const explicit = appPath ?? env.OPEN_SCIENCE_APP_PATH
|
|
65
|
+
if (explicit) {
|
|
66
|
+
const command = resolve(explicit)
|
|
67
|
+
if (!(await exists(command))) throw new Error(`Open-Science executable not found: ${command}`)
|
|
68
|
+
return { command, args: [], packaged: true, repositoryRoot }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const development = await locateDevelopmentApp()
|
|
72
|
+
if (development) return development
|
|
73
|
+
|
|
74
|
+
for (const candidate of defaultInstalledCandidates(env)) {
|
|
75
|
+
if ((await exists(candidate)) && !(await isCurrentCli(candidate))) {
|
|
76
|
+
return { command: candidate, args: [], packaged: true, repositoryRoot }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// When run from a packaged build's resources dir there is no package.json next to the CLI, so fall
|
|
81
|
+
// back to the product name rather than letting the read mask the real "could not locate" message.
|
|
82
|
+
let productName = 'Open-Science'
|
|
83
|
+
try {
|
|
84
|
+
productName =
|
|
85
|
+
JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')).productName ??
|
|
86
|
+
productName
|
|
87
|
+
} catch {
|
|
88
|
+
// No package.json alongside the CLI (packaged): keep the default product name.
|
|
89
|
+
}
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Could not locate ${productName}. Run "npm run build" in the repository, install the app, or pass --app-path / set OPEN_SCIENCE_APP_PATH to its executable.`
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { repositoryRoot }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
2
|
+
import { access, readFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { createRequire } from 'node:module'
|
|
6
|
+
import { currentRuntimeTarget, runtimePackageName } from './runtime-package.mjs'
|
|
7
|
+
|
|
8
|
+
export async function locateBackend({ required = true } = {}) {
|
|
9
|
+
if (process.versions.electron)
|
|
10
|
+
throw new Error(
|
|
11
|
+
'The CLI requires ordinary Node.js. Reinstall the desktop command-line launcher.'
|
|
12
|
+
)
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
for (const root of [here, join(here, '../..')]) {
|
|
15
|
+
const entry = join(root, 'out/backend/index.cjs')
|
|
16
|
+
try {
|
|
17
|
+
await access(entry)
|
|
18
|
+
} catch {
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
command: process.execPath,
|
|
23
|
+
entry,
|
|
24
|
+
development: root !== here
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const manifest = JSON.parse(await readFile(join(here, 'package.json'), 'utf8'))
|
|
28
|
+
// Only the registry entry package opts in; desktop launchers and source checkouts keep their paths.
|
|
29
|
+
if (manifest.optionalDependencies?.[runtimePackageName({ id: 'darwin-arm64' })]) {
|
|
30
|
+
const name = runtimePackageName(currentRuntimeTarget())
|
|
31
|
+
let packageFile
|
|
32
|
+
try {
|
|
33
|
+
packageFile = createRequire(import.meta.url).resolve(`${name}/package.json`)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code !== 'MODULE_NOT_FOUND') throw error
|
|
36
|
+
if (!required) return { development: false }
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Missing ${name}@${manifest.version}. Reinstall @aipoch/open-science with npm --include=optional.`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
const native = JSON.parse(await readFile(packageFile, 'utf8'))
|
|
42
|
+
if (native.name !== name || native.version !== manifest.version)
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Open-Science native package version mismatch: expected ${name}@${manifest.version}. Reinstall the package.`
|
|
45
|
+
)
|
|
46
|
+
const entry = join(dirname(packageFile), 'out/backend/index.cjs')
|
|
47
|
+
await access(entry)
|
|
48
|
+
return { command: process.execPath, entry, development: false }
|
|
49
|
+
}
|
|
50
|
+
if (!required) {
|
|
51
|
+
// Profile initialization/offline login also work with a lightweight CLI or desktop launcher.
|
|
52
|
+
// Only a real source checkout selects the development profile without a built backend.
|
|
53
|
+
try {
|
|
54
|
+
await access(join(here, '../../src/main/node-entry.ts'))
|
|
55
|
+
return { development: true }
|
|
56
|
+
} catch {
|
|
57
|
+
return { development: false }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new Error(
|
|
61
|
+
'The standalone Node backend is missing. Install the standalone Open-Science package, or run npm run build:backend in the repository.'
|
|
62
|
+
)
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aipoch/open-science",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Node.js SDK and CLI for the Open-Science local daemon",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./browser-import": {
|
|
8
|
+
"types": "./browser-import.d.ts",
|
|
9
|
+
"import": "./browser-import.mjs"
|
|
10
|
+
},
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"import": "./index.mjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"open-science": "./cli.mjs"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"*.mjs",
|
|
21
|
+
"*.cjs",
|
|
22
|
+
"*.d.ts",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.md",
|
|
25
|
+
"CLI.md",
|
|
26
|
+
"BROWSER_IMPORT.md"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.13.0"
|
|
30
|
+
},
|
|
31
|
+
"license": "Apache-2.0",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/aipoch/open-science.git",
|
|
35
|
+
"directory": "packages/open-science"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"optionalDependencies": {
|
|
41
|
+
"@aipoch/open-science-darwin-arm64": "1.0.2",
|
|
42
|
+
"@aipoch/open-science-darwin-x64": "1.0.2",
|
|
43
|
+
"@aipoch/open-science-linux-x64-gnu": "1.0.2",
|
|
44
|
+
"@aipoch/open-science-linux-arm64-gnu": "1.0.2",
|
|
45
|
+
"@aipoch/open-science-win32-x64": "1.0.2"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { candidateConfigRoots, readWebToken } from './config-root.mjs'
|
|
5
|
+
|
|
6
|
+
// Discovery records are hints. A live authenticated response from the same generation is authority.
|
|
7
|
+
export async function requestExistingRuntimeWeb(options, fetch = globalThis.fetch) {
|
|
8
|
+
for (const configRoot of candidateConfigRoots({ override: options.configRoot })) {
|
|
9
|
+
let record
|
|
10
|
+
try {
|
|
11
|
+
record = JSON.parse(await readFile(join(configRoot, 'runtime-owner.json'), 'utf8'))
|
|
12
|
+
} catch {
|
|
13
|
+
continue
|
|
14
|
+
}
|
|
15
|
+
if (
|
|
16
|
+
record.schemaVersion !== 1 ||
|
|
17
|
+
!Number.isSafeInteger(record.pid) ||
|
|
18
|
+
record.pid < 1 ||
|
|
19
|
+
!Number.isSafeInteger(record.port) ||
|
|
20
|
+
record.port < 1 ||
|
|
21
|
+
record.port > 65535 ||
|
|
22
|
+
!['node', 'electron'].includes(record.host) ||
|
|
23
|
+
typeof record.generation !== 'string'
|
|
24
|
+
)
|
|
25
|
+
continue
|
|
26
|
+
let response
|
|
27
|
+
try {
|
|
28
|
+
response = await fetch(
|
|
29
|
+
`http://127.0.0.1:${record.port}${options.credentialStore !== undefined ? '/owner' : `/web/start?port=${options.port ?? 44100}`}`,
|
|
30
|
+
{
|
|
31
|
+
method: options.credentialStore !== undefined ? 'GET' : 'POST',
|
|
32
|
+
headers: {
|
|
33
|
+
authorization: `Bearer ${await readWebToken(configRoot)}`,
|
|
34
|
+
'x-open-science-runtime-generation': record.generation
|
|
35
|
+
},
|
|
36
|
+
signal: AbortSignal.timeout(30_000)
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
if (!response.ok) continue
|
|
40
|
+
const result = await response.json()
|
|
41
|
+
if (result.generation !== record.generation || result.pid !== record.pid) continue
|
|
42
|
+
} catch {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (options.credentialStore !== undefined)
|
|
46
|
+
throw new Error(
|
|
47
|
+
'Open-Science is already running. Stop it before selecting a credential store.'
|
|
48
|
+
)
|
|
49
|
+
return configRoot
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
2
|
+
|
|
3
|
+
// This is the published native support set, shared by packing and runtime discovery.
|
|
4
|
+
export const runtimeTargets = [
|
|
5
|
+
{ id: 'darwin-arm64', os: 'darwin', cpu: 'arm64' },
|
|
6
|
+
{ id: 'darwin-x64', os: 'darwin', cpu: 'x64' },
|
|
7
|
+
{ id: 'linux-x64-gnu', os: 'linux', cpu: 'x64', libc: 'glibc' },
|
|
8
|
+
{ id: 'linux-arm64-gnu', os: 'linux', cpu: 'arm64', libc: 'glibc' },
|
|
9
|
+
{ id: 'win32-x64', os: 'win32', cpu: 'x64' }
|
|
10
|
+
]
|
|
11
|
+
export const runtimePackageName = (target) => `@aipoch/open-science-${target.id}`
|
|
12
|
+
export function currentRuntimeTarget(platform = process.platform, arch = process.arch) {
|
|
13
|
+
const target = runtimeTargets.find((item) => item.os === platform && item.cpu === arch)
|
|
14
|
+
if (!target || (platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime))
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Open-Science has no native npm package for ${platform}/${arch}. Linux requires glibc.`
|
|
17
|
+
)
|
|
18
|
+
return target
|
|
19
|
+
}
|