@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.
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }