Skip to content

Commit 52f7269

Browse files
committed
feat(cli): add CLI scaffold, app bootstrap, daemon, and shared libraries
1 parent 082fd47 commit 52f7269

29 files changed

Lines changed: 1456 additions & 31 deletions

.changeset/cli-scaffold-daemon.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
cli: minor
3+
---
4+
5+
Add CLI application with daemon-based architecture, background service scheduling, and core utility libraries.

apps/cli/jest.config.cjs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const sharedTransform = {
2+
'^.+\\.tsx?$': [
3+
'ts-jest',
4+
{
5+
tsconfig: {
6+
target: 'ES2022',
7+
module: 'commonjs',
8+
moduleResolution: 'node',
9+
esModuleInterop: true,
10+
strict: true,
11+
baseUrl: '.',
12+
paths: {
13+
'@siastorage/logger': ['../../packages/logger/src/index.ts'],
14+
'@siastorage/core/*': ['../../packages/core/src/*'],
15+
'@siastorage/node-adapters': ['../../packages/node-adapters/src/index.ts'],
16+
'@siastorage/node-adapters/*': ['../../packages/node-adapters/src/*'],
17+
'@siastorage/sdk-mock': ['../../packages/sdk-mock/src/index.ts'],
18+
'@siafoundation/sia-storage': [
19+
'../../node_modules/@siafoundation/sia-storage/dist/index.node.d.ts',
20+
],
21+
'bun:sqlite': ['./test/__mocks__/bun-sqlite.ts'],
22+
},
23+
},
24+
},
25+
],
26+
}
27+
28+
const sharedModuleNameMapper = {
29+
'^@siastorage/logger$': '<rootDir>/../../packages/logger/src/index.ts',
30+
'^@siastorage/core/(.*)$': '<rootDir>/../../packages/core/src/$1',
31+
'^@siastorage/node-adapters$': '<rootDir>/../../packages/node-adapters/src/index.ts',
32+
'^@siastorage/node-adapters/(.*)$': '<rootDir>/../../packages/node-adapters/src/$1',
33+
'^@siastorage/sdk-mock$': '<rootDir>/../../packages/sdk-mock/src/index.ts',
34+
'^@siafoundation/sia-storage$': '<rootDir>/test/__mocks__/@siafoundation/sia-storage.ts',
35+
'^bun:sqlite$': '<rootDir>/test/__mocks__/bun-sqlite.ts',
36+
}
37+
38+
module.exports = {
39+
projects: [
40+
{
41+
displayName: 'unit',
42+
testEnvironment: 'node',
43+
testMatch: ['<rootDir>/test/**/*.test.ts'],
44+
testPathIgnorePatterns: ['<rootDir>/test/e2e/'],
45+
transform: sharedTransform,
46+
moduleNameMapper: sharedModuleNameMapper,
47+
},
48+
{
49+
displayName: 'e2e',
50+
testEnvironment: 'node',
51+
maxWorkers: 4,
52+
testMatch: ['<rootDir>/test/e2e/**/*.test.ts'],
53+
setupFiles: ['<rootDir>/test/e2e/setup.ts'],
54+
transform: sharedTransform,
55+
moduleNameMapper: sharedModuleNameMapper,
56+
},
57+
],
58+
}

apps/cli/package.json

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
{
22
"name": "@siastorage/cli",
3-
"version": "0.0.0",
4-
"private": true
3+
"version": "0.0.1",
4+
"private": true,
5+
"bin": {
6+
"sia": "src/index.ts"
7+
},
8+
"scripts": {
9+
"dev": "bun run src/index.ts",
10+
"build": "bun build src/index.ts --compile --outfile dist/sia",
11+
"build:linux": "bun build src/index.ts --compile --target=bun-linux-x64 --outfile dist/sia-linux",
12+
"link": "bun run build && ln -sf $(pwd)/dist/sia ~/.sia/bin/sia",
13+
"lint": "oxlint . && oxfmt --check .",
14+
"typecheck": "tsc --noEmit",
15+
"test": "jest --selectProjects unit",
16+
"test:e2e": "bun run build && jest --selectProjects e2e",
17+
"test:all": "bun run build && jest"
18+
},
19+
"dependencies": {
20+
"@clack/prompts": "1.2.0",
21+
"@siastorage/core": "workspace:*",
22+
"@siastorage/logger": "workspace:*",
23+
"@siastorage/node-adapters": "workspace:*",
24+
"@siastorage/sdk-mock": "workspace:*",
25+
"commander": "14.0.3",
26+
"lucide-react": "1.8.0",
27+
"react": "19.2.0",
28+
"react-dom": "19.2.0",
29+
"swr": "2.4.1"
30+
},
31+
"devDependencies": {
32+
"@tailwindcss/cli": "4.2.2",
33+
"@types/node": "22.15.2",
34+
"@types/react": "19.2.14",
35+
"@types/react-dom": "19.2.3",
36+
"jest": "29.7.0",
37+
"tailwindcss": "4.2.2",
38+
"ts-jest": "29.4.6",
39+
"typescript": "5.9.3"
40+
}
541
}

apps/cli/src/app.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { uint8ToHex } from '@siastorage/core'
2+
import type { DatabaseAdapter, SdkAdapter, SdkAuthAdapters } from '@siastorage/core/adapters'
3+
// oxlint-disable-next-line no-restricted-imports -- CLI daemon needs internal API to set SDK
4+
import type { AppService, AppServiceInternal } from '@siastorage/core/app'
5+
import { createAppService } from '@siastorage/core/app'
6+
import { APP_META } from '@siastorage/core/config'
7+
import { runMigrations } from '@siastorage/core/db'
8+
import { coreMigrations, sortMigrations } from '@siastorage/core/db/migrations'
9+
import type { FsIOAdapter } from '@siastorage/core/services/fsFileUri'
10+
import type { UploadManager } from '@siastorage/core/services/uploader'
11+
import {
12+
createJsonFileStorage,
13+
createNodeCryptoAdapter,
14+
createNodeDetectMimeType,
15+
createNodeDownloadAdapter,
16+
createNodeFsIO,
17+
createNodeSdkAdapter,
18+
createNodeSdkAuthAdapter,
19+
createNodeUploaderAdapters,
20+
createSharpThumbnailAdapter,
21+
ensureDataDir,
22+
getDataDir,
23+
getPaths,
24+
type NodeSdkAuthResult,
25+
} from '@siastorage/node-adapters'
26+
import { createBunDatabase } from '@siastorage/node-adapters/bunDatabase'
27+
28+
export const isTestMode = process.env.SIA_TEST_MODE === '1'
29+
30+
export type CliApp = {
31+
service: AppService
32+
internal: AppServiceInternal
33+
uploadManager: UploadManager
34+
db: DatabaseAdapter
35+
fsIO: FsIOAdapter
36+
paths: ReturnType<typeof getPaths>
37+
bootstrap: Bootstrap
38+
}
39+
40+
export type CreateDatabaseFn = (path: string) => DatabaseAdapter
41+
42+
/**
43+
* Bundle of auth + SDK adapters chosen at startup. Holds the strategy for
44+
* connecting the SDK so callers don't need to know whether they're running
45+
* against a real indexer or a MockSdk.
46+
*/
47+
export type Bootstrap = {
48+
authAdapters: SdkAuthAdapters
49+
sdkAuth: NodeSdkAuthResult
50+
/** Set only when running with `SIA_TEST_MODE=1` — pre-attached during bootstrap. */
51+
testSdkAdapter?: SdkAdapter
52+
/** Performs the SDK connection (real handshake or test-mode short-circuit). */
53+
connect: (app: CliApp) => Promise<boolean>
54+
}
55+
56+
async function buildBootstrap(): Promise<Bootstrap> {
57+
if (isTestMode) {
58+
const { createTestBootstrap } = await import('./testMode')
59+
return createTestBootstrap()
60+
}
61+
return createRealBootstrap()
62+
}
63+
64+
function createRealBootstrap(): Bootstrap {
65+
const sdkAuth = createNodeSdkAuthAdapter()
66+
return {
67+
authAdapters: sdkAuth.adapters,
68+
sdkAuth,
69+
async connect(app) {
70+
const indexerURL = await app.service.settings.getIndexerURL()
71+
const keyBytes = await app.service.auth.getAppKey(indexerURL)
72+
if (!keyBytes) return false
73+
74+
const keyHex = uint8ToHex(new Uint8Array(keyBytes))
75+
await sdkAuth.adapters.createBuilder(indexerURL, JSON.stringify(APP_META))
76+
77+
const connected = await sdkAuth.adapters.connectWithKey(keyHex)
78+
if (!connected) return false
79+
80+
const sdk = sdkAuth.getLastSdk()
81+
if (!sdk) return false
82+
83+
app.internal.setSdk(createNodeSdkAdapter(sdk))
84+
app.service.connection.setState({ isConnected: true })
85+
app.internal.initUploader()
86+
return true
87+
},
88+
}
89+
}
90+
91+
export async function createCliAppService(
92+
dataDir?: string,
93+
opts?: { createDatabase?: CreateDatabaseFn },
94+
): Promise<CliApp> {
95+
const dir = dataDir ?? getDataDir()
96+
const p = getPaths(dir)
97+
ensureDataDir(dir)
98+
99+
const createDb = opts?.createDatabase ?? createBunDatabase
100+
const db = createDb(p.dbPath)
101+
await runMigrations(db, sortMigrations(coreMigrations))
102+
103+
const storage = createJsonFileStorage(p.storagePath)
104+
const secrets = createJsonFileStorage(p.secretsPath, { mode: 0o600 })
105+
const crypto = createNodeCryptoAdapter()
106+
const fsIO = createNodeFsIO(p.filesDir)
107+
const uploaderAdapters = createNodeUploaderAdapters()
108+
const detectMimeType = createNodeDetectMimeType()
109+
const thumbnail = createSharpThumbnailAdapter()
110+
111+
const bootstrap = await buildBootstrap()
112+
113+
const { service, internal, uploadManager } = createAppService({
114+
db,
115+
storage,
116+
secrets,
117+
crypto,
118+
fsIO,
119+
downloadObject: createNodeDownloadAdapter({
120+
fsIO,
121+
getAppKey: async (indexerURL: string) => service.auth.getAppKey(indexerURL),
122+
}),
123+
uploader: uploaderAdapters,
124+
sdkAuth: bootstrap.authAdapters,
125+
thumbnail,
126+
detectMimeType,
127+
})
128+
129+
// In test mode the MockSdk attaches before connectSdk runs, so the daemon
130+
// comes up with an SDK already wired and connectSdk just flips the flag.
131+
if (bootstrap.testSdkAdapter) {
132+
internal.setSdk(bootstrap.testSdkAdapter)
133+
}
134+
135+
return { service, internal, uploadManager, db, fsIO, paths: p, bootstrap }
136+
}
137+
138+
/** Performs the SDK connection chosen at bootstrap (real or test-mode). */
139+
export function connectSdk(app: CliApp): Promise<boolean> {
140+
return app.bootstrap.connect(app)
141+
}

apps/cli/src/daemon/entry.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { logger } from '@siastorage/logger'
2+
import { writeState } from '@siastorage/node-adapters'
3+
import { connectSdk, createCliAppService } from '../app'
4+
import { startIpcDispatcher } from './ipc'
5+
import {
6+
acquireLockOrExit,
7+
attachSignalHandlers,
8+
executeShutdown,
9+
type ShutdownContext,
10+
} from './lifecycle'
11+
import { initializeScheduler } from './scheduler'
12+
13+
export type DaemonContext = ShutdownContext & {
14+
connected: boolean
15+
shutdown: () => Promise<void>
16+
}
17+
18+
/**
19+
* Boots the daemon end-to-end: app service, single-instance lock, SDK
20+
* connection, scheduled background services, IPC server, signal handlers.
21+
* Shared by both `sia daemon start` and `sia serve`; the latter wraps the
22+
* returned context with an HTTP server.
23+
*/
24+
export async function startServices(dataDir?: string): Promise<DaemonContext> {
25+
const app = await createCliAppService(dataDir)
26+
const lock = acquireLockOrExit(app.paths)
27+
28+
let connected = false
29+
try {
30+
connected = await connectSdk(app)
31+
} catch (e) {
32+
logger.warn('daemon', 'sdk_connect_failed', { error: e as Error })
33+
}
34+
35+
const { scheduler } = initializeScheduler(app)
36+
37+
writeState(app.paths.statePath, {
38+
pid: process.pid,
39+
startedAt: Date.now(),
40+
connected,
41+
})
42+
43+
// The IPC server's `shutdown` handler needs to call back into the shutdown
44+
// function — but the function references the IPC server itself. Resolve by
45+
// declaring `ctx` first and assigning after both are constructed.
46+
let ctx: ShutdownContext
47+
const shutdown = () => executeShutdown(ctx)
48+
const ipcServer = startIpcDispatcher(app, app.paths.sockPath, () => {
49+
void shutdown()
50+
})
51+
ctx = { app, scheduler, ipcServer, lock }
52+
53+
attachSignalHandlers(shutdown)
54+
55+
return { ...ctx, connected, shutdown }
56+
}
57+
58+
export async function startDaemon(dataDir?: string): Promise<void> {
59+
const ctx = await startServices(dataDir)
60+
logger.info('daemon', 'started', { pid: process.pid, connected: ctx.connected })
61+
console.log(`Daemon started (PID: ${process.pid})`)
62+
}

apps/cli/src/daemon/ipc/index.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { registerAppServiceIpc } from '@siastorage/core/app'
2+
import { startIpcServer } from '@siastorage/node-adapters'
3+
import type { CliApp } from '../../app'
4+
import { registerStatusHandlers } from './status'
5+
import { registerUploadHandlers } from './upload'
6+
7+
export type IpcHandler = (params: Record<string, unknown>) => Promise<unknown>
8+
export type IpcHandlerMap = Map<string, IpcHandler>
9+
10+
/**
11+
* Wires the daemon's IPC server. Custom handlers (`ping`, `status`, `upload`,
12+
* `uploadState`, `shutdown`) are registered explicitly; the AppService facade
13+
* is reflected onto `ds:<namespace>:<method>` channels via
14+
* `registerAppServiceIpc`. Both flow through the same handler map. Feature
15+
* branches (e.g. `cli/watch`) extend the handler map by registering their own
16+
* custom handlers in this same way.
17+
*/
18+
export function startIpcDispatcher(
19+
app: CliApp,
20+
sockPath: string,
21+
onShutdown: () => void,
22+
): ReturnType<typeof startIpcServer> {
23+
const handlers: IpcHandlerMap = new Map()
24+
25+
registerStatusHandlers(handlers, app, onShutdown)
26+
registerUploadHandlers(handlers, app)
27+
28+
registerAppServiceIpc(
29+
{
30+
handle: (channel, handler) => {
31+
handlers.set(channel, async (params) => {
32+
const args = (params as { args?: unknown[] })?.args ?? []
33+
return handler(null, ...args)
34+
})
35+
},
36+
},
37+
app.service,
38+
)
39+
40+
return startIpcServer(sockPath, async (method, params) => {
41+
const handler = handlers.get(method)
42+
if (!handler) throw new Error(`Unknown method: ${method}`)
43+
return handler(params)
44+
})
45+
}

apps/cli/src/daemon/ipc/status.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { CliApp } from '../../app'
2+
import type { IpcHandlerMap } from './index'
3+
4+
/** Handlers for daemon-process-level queries (not app state). */
5+
export function registerStatusHandlers(
6+
handlers: IpcHandlerMap,
7+
app: CliApp,
8+
onShutdown: () => void,
9+
): void {
10+
handlers.set('ping', async () => ({ ok: true }))
11+
12+
handlers.set('status', async () => ({
13+
running: true,
14+
pid: process.pid,
15+
connected: app.service.connection.getState().isConnected,
16+
}))
17+
18+
handlers.set('shutdown', async () => {
19+
onShutdown()
20+
return { ok: true }
21+
})
22+
}

0 commit comments

Comments
 (0)