Skip to content

Commit 9d567f8

Browse files
committed
feat(cli): add file and directory management commands
1 parent 893eb17 commit 9d567f8

21 files changed

Lines changed: 1440 additions & 0 deletions

.changeset/cli-file-commands.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 ls, mkdir, rm, mv, add, download, import, info, and reset commands.

apps/cli/src/commands/add.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { resolve } from 'path'
2+
import { getPaths } from '@siastorage/node-adapters'
3+
import { daemonCommand } from '../daemon/supervisor'
4+
import { c } from '../lib/format'
5+
import { normalizePath } from '../lib/normalizePath'
6+
7+
export async function addCommand(dataDir: string, filePath: string, opts?: { dir?: string }) {
8+
const p = getPaths(dataDir)
9+
const absPath = resolve(filePath)
10+
const directory = opts?.dir ? normalizePath(opts.dir) : undefined
11+
12+
try {
13+
const result = (await daemonCommand(p, 'upload', {
14+
path: absPath,
15+
directory,
16+
})) as { id: string; name: string }
17+
18+
console.log(`Added ${c.green(result.name)} (${c.dim(result.id)})`)
19+
} catch (e) {
20+
console.error(`Add failed: ${e instanceof Error ? e.message : String(e)}`)
21+
process.exit(1)
22+
}
23+
}

apps/cli/src/commands/download.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { getPaths } from '@siastorage/node-adapters'
2+
import { daemonCommand, ensureDaemonRunning } from '../daemon/supervisor'
3+
import { createDaemonClient } from '../lib/appServiceClient'
4+
import { c } from '../lib/format'
5+
import { resolveFile } from '../lib/resolveFile'
6+
7+
export async function downloadCommand(dataDir: string, file: string, opts?: { output?: string }) {
8+
const p = getPaths(dataDir)
9+
await ensureDaemonRunning(p)
10+
const app = createDaemonClient(p.sockPath)
11+
12+
const record = await resolveFile(app, file)
13+
if (!record) {
14+
console.error(`File not found: ${file}`)
15+
process.exit(1)
16+
}
17+
18+
try {
19+
await daemonCommand(p, 'download', {
20+
fileId: record.id,
21+
output: opts?.output,
22+
})
23+
console.log(`Downloaded ${c.green(record.name)}`)
24+
} catch (e) {
25+
console.error(`Download failed: ${e instanceof Error ? e.message : String(e)}`)
26+
process.exit(1)
27+
}
28+
}

apps/cli/src/commands/import.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import * as fs from 'node:fs'
2+
import * as path from 'node:path'
3+
import { createHash } from 'node:crypto'
4+
import { getPaths } from '@siastorage/node-adapters'
5+
import { daemonCommand, ensureDaemonRunning } from '../daemon/supervisor'
6+
import { createDaemonClient } from '../lib/appServiceClient'
7+
import { c, formatBytes } from '../lib/format'
8+
import { normalizePath } from '../lib/normalizePath'
9+
10+
type ImportOpts = {
11+
dryRun?: boolean
12+
skipExisting?: boolean
13+
}
14+
15+
export async function importCommand(
16+
dataDir: string,
17+
localPath: string,
18+
remoteDir: string | undefined,
19+
opts: ImportOpts,
20+
) {
21+
const absPath = path.resolve(localPath)
22+
23+
const stat = fs.statSync(absPath, { throwIfNoEntry: false })
24+
if (!stat) {
25+
console.error(`Path not found: ${absPath}`)
26+
process.exit(1)
27+
}
28+
if (!stat.isDirectory()) {
29+
console.error(`Not a directory: ${absPath}`)
30+
console.error('Use "sia add" for single files.')
31+
process.exit(1)
32+
}
33+
34+
const baseName = remoteDir ? normalizePath(remoteDir) : path.basename(absPath)
35+
const files = walkDirectory(absPath)
36+
37+
if (files.length === 0) {
38+
console.log('No files found.')
39+
return
40+
}
41+
42+
const totalSize = files.reduce((sum, f) => sum + f.size, 0)
43+
44+
if (opts.dryRun) {
45+
console.log(
46+
`Would import ${files.length} files (${formatBytes(totalSize)}) into ${baseName}/\n`,
47+
)
48+
for (const f of files) {
49+
const remotePath = path.join(baseName, f.relativePath)
50+
console.log(` ${remotePath} ${c.dim(`(${formatBytes(f.size)})`)}`)
51+
}
52+
return
53+
}
54+
55+
const p = getPaths(dataDir)
56+
await ensureDaemonRunning(p)
57+
const app = createDaemonClient(p.sockPath)
58+
59+
let imported = 0
60+
let skipped = 0
61+
let importedBytes = 0
62+
63+
console.log(`Importing from ${absPath} -> ${baseName}/\n`)
64+
65+
for (const file of files) {
66+
const relDir = path.dirname(file.relativePath)
67+
const directory = relDir === '.' ? baseName : path.join(baseName, relDir)
68+
const displayPath = path.join(baseName, file.relativePath)
69+
70+
if (opts.skipExisting) {
71+
const data = fs.readFileSync(file.absolutePath)
72+
const hash = createHash('sha256').update(data).digest('hex')
73+
const existing = await app.files.getByContentHash(hash)
74+
if (existing) {
75+
skipped++
76+
console.log(
77+
` ${c.dim(`[${pad(imported + skipped, files.length)}/${files.length}]`)} ${c.dim(displayPath)} ${c.dim('(skipped)')}`,
78+
)
79+
continue
80+
}
81+
}
82+
83+
const result = (await daemonCommand(p, 'upload', {
84+
path: file.absolutePath,
85+
directory,
86+
})) as { id: string; name: string; size: number; type: string }
87+
88+
imported++
89+
importedBytes += result.size
90+
const typeBadge = result.type.split('/')[1] ?? result.type
91+
console.log(
92+
` ${c.dim(`[${pad(imported + skipped, files.length)}/${files.length}]`)} ${displayPath} ${c.dim(typeBadge)} ${c.dim(`(${formatBytes(result.size)})`)}`,
93+
)
94+
}
95+
96+
console.log(
97+
`\nImported ${imported} files (${formatBytes(importedBytes)})` +
98+
(skipped > 0 ? `, skipped ${skipped}` : ''),
99+
)
100+
}
101+
102+
type FileEntry = {
103+
absolutePath: string
104+
relativePath: string
105+
size: number
106+
}
107+
108+
function walkDirectory(dir: string, base: string = ''): FileEntry[] {
109+
const entries: FileEntry[] = []
110+
const items = fs.readdirSync(dir, { withFileTypes: true })
111+
112+
for (const item of items) {
113+
const absPath = path.join(dir, item.name)
114+
const relPath = base ? path.join(base, item.name) : item.name
115+
116+
if (item.isDirectory()) {
117+
entries.push(...walkDirectory(absPath, relPath))
118+
} else if (item.isFile()) {
119+
const stat = fs.statSync(absPath)
120+
entries.push({ absolutePath: absPath, relativePath: relPath, size: stat.size })
121+
}
122+
}
123+
124+
return entries
125+
}
126+
127+
function pad(n: number, total: number): string {
128+
return String(n).padStart(String(total).length, ' ')
129+
}

apps/cli/src/commands/info.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { getPaths } from '@siastorage/node-adapters'
2+
import { ensureDaemonRunning } from '../daemon/supervisor'
3+
import { createDaemonClient } from '../lib/appServiceClient'
4+
import { c, formatBytes, formatDate } from '../lib/format'
5+
import { resolveFile } from '../lib/resolveFile'
6+
7+
export async function infoCommand(dataDir: string, file: string) {
8+
const p = getPaths(dataDir)
9+
await ensureDaemonRunning(p)
10+
const app = createDaemonClient(p.sockPath)
11+
12+
const record = await resolveFile(app, file)
13+
14+
if (!record) {
15+
console.error(`File not found: ${file}`)
16+
process.exit(1)
17+
}
18+
19+
console.log(`${c.bold('Name')}: ${record.name}`)
20+
console.log(`${c.bold('ID')}: ${record.id}`)
21+
console.log(`${c.bold('Type')}: ${record.type}`)
22+
console.log(`${c.bold('Size')}: ${formatBytes(record.size as number)}`)
23+
console.log(`${c.bold('Hash')}: ${record.hash}`)
24+
console.log(`${c.bold('Created')}: ${formatDate(record.createdAt as number)}`)
25+
console.log(`${c.bold('Updated')}: ${formatDate(record.updatedAt as number)}`)
26+
27+
const dirPath = await app.directories.getPathForFile(record.id)
28+
if (dirPath) {
29+
console.log(`${c.bold('Directory')}: ${dirPath}`)
30+
}
31+
32+
const tags = await app.tags.getForFile(record.id)
33+
if (tags.length > 0) {
34+
console.log(`${c.bold('Tags')}: ${tags.map((t: any) => t.name).join(', ')}`)
35+
}
36+
}

apps/cli/src/commands/ls.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { getPaths } from '@siastorage/node-adapters'
2+
import { UNFILED_DIRECTORY_ID } from '@siastorage/core/db/operations'
3+
import { ensureDaemonRunning } from '../daemon/supervisor'
4+
import { createDaemonClient } from '../lib/appServiceClient'
5+
import { c, formatBytes, formatRelativeDate, table } from '../lib/format'
6+
import { normalizePath } from '../lib/normalizePath'
7+
8+
export async function lsCommand(
9+
dataDir: string,
10+
dirPath?: string,
11+
_opts?: { sort?: string; type?: string; tag?: string },
12+
) {
13+
const p = getPaths(dataDir)
14+
await ensureDaemonRunning(p)
15+
const app = createDaemonClient(p.sockPath)
16+
17+
const indexerURL = await app.settings.getIndexerURL()
18+
let uploadedIds = new Set<string>()
19+
if (indexerURL) {
20+
const ids = await app.files.getUploadedIds(indexerURL)
21+
uploadedIds = new Set(ids)
22+
}
23+
24+
if (!dirPath) {
25+
const directories = await app.directories.getChildren(null)
26+
const unfiledFiles = await app.files.queryLibrary({
27+
directoryId: UNFILED_DIRECTORY_ID,
28+
limit: 200,
29+
})
30+
31+
const dirRows: string[][] = directories.map((d: any) => [
32+
c.cyan(d.name),
33+
String(d.fileCount ?? 0),
34+
])
35+
36+
if (dirRows.length > 0) {
37+
console.log(table(['DIRECTORY', 'FILES'], dirRows))
38+
}
39+
40+
if (unfiledFiles.length > 0) {
41+
if (dirRows.length > 0) console.log()
42+
printFileTable(unfiledFiles, uploadedIds)
43+
}
44+
45+
if (dirRows.length === 0 && unfiledFiles.length === 0) {
46+
console.log(c.dim('No files'))
47+
}
48+
return
49+
}
50+
51+
const normalized = normalizePath(dirPath)
52+
53+
const dir = await app.directories.getByPath(normalized)
54+
if (!dir) {
55+
console.error(`Directory not found: ${normalized}`)
56+
process.exit(1)
57+
}
58+
59+
const childDirs = await app.directories.getChildren(normalized)
60+
if (childDirs.length > 0) {
61+
console.log(
62+
table(
63+
['DIRECTORY', 'FILES'],
64+
childDirs.map((d: any) => [c.cyan(d.name), String(d.fileCount ?? 0)]),
65+
),
66+
)
67+
}
68+
69+
const files = await app.files.queryLibrary({
70+
directoryId: dir.id,
71+
limit: 200,
72+
})
73+
74+
if (files.length > 0) {
75+
if (childDirs.length > 0) console.log()
76+
printFileTable(files, uploadedIds)
77+
} else if (childDirs.length === 0) {
78+
console.log(c.dim('No files'))
79+
}
80+
}
81+
82+
function printFileTable(
83+
files: Array<{
84+
id: string
85+
name: string
86+
type: string
87+
size: number
88+
hash: string
89+
updatedAt: number
90+
}>,
91+
uploadedIds: Set<string>,
92+
) {
93+
console.log(
94+
table(
95+
['NAME', 'TYPE', 'SIZE', 'STATUS', 'MODIFIED'],
96+
files.map((f) => {
97+
const typeColor = f.type.startsWith('image/')
98+
? c.cyan
99+
: f.type.startsWith('video/')
100+
? c.magenta
101+
: f.type.startsWith('audio/')
102+
? c.yellow
103+
: c.dim
104+
const status =
105+
f.hash === ''
106+
? c.dim('processing')
107+
: uploadedIds.has(f.id)
108+
? c.green('uploaded')
109+
: c.yellow('local')
110+
return [
111+
f.name,
112+
typeColor(f.type.split('/')[1] ?? f.type),
113+
formatBytes(f.size),
114+
status,
115+
formatRelativeDate(f.updatedAt),
116+
]
117+
}),
118+
),
119+
)
120+
}

apps/cli/src/commands/mkdir.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { getPaths } from '@siastorage/node-adapters'
2+
import { ensureDaemonRunning } from '../daemon/supervisor'
3+
import { createDaemonClient } from '../lib/appServiceClient'
4+
import { c } from '../lib/format'
5+
import { normalizePath } from '../lib/normalizePath'
6+
7+
export async function mkdirCommand(dataDir: string, name: string) {
8+
const p = getPaths(dataDir)
9+
await ensureDaemonRunning(p)
10+
const app = createDaemonClient(p.sockPath)
11+
12+
const normalized = normalizePath(name)
13+
14+
try {
15+
const dir = await app.directories.getOrCreateAtPath(normalized)
16+
console.log(`Created directory ${c.cyan(dir.path)}`)
17+
} catch (e) {
18+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`)
19+
process.exit(1)
20+
}
21+
}

0 commit comments

Comments
 (0)