mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3492 from vitorpamplona/claude/amethyst-status-command-yf5be6
Add `amy status` command for cross-account disk overview
This commit is contained in:
@@ -374,6 +374,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
|
||||
| `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). |
|
||||
| `amy whoami` | Print the active account's name + npub. |
|
||||
| `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. |
|
||||
| `amy status` | Read-only overview of everything under `~/.amy/`: every account, which one is current, each signer type (local keychain/ncryptsec/plaintext, NIP-46 bunker, or read-only) and whether it can sign, the local Marmot / Cashu / alias / sync-cursor footprint per account, and the shared event store's size. Built for the returning user. No keychain prompt, no network. |
|
||||
| `amy logoff [--yes] [--keep-events]` | Log off an account: delete its key + backend secret, the whole `~/.amy/<account>/` directory (run-state, aliases, cashu counters, Marmot state), the `current` pin if it points here, and the account's events (authored + `#p`-addressed) in the shared store. `--keep-events` leaves the shared cache alone. Destructive and irreversible — requires `--yes`; without it, prints a dry run and exits 2. |
|
||||
|
||||
### Social
|
||||
|
||||
@@ -44,6 +44,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 |
|
||||
| Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` |
|
||||
| Account logoff (`amy logoff`) — delete key + per-account state + the account's events in the shared store | ✅ | `LogoffCommand`. `--yes`-gated; `--keep-events` skips the shared-cache purge. |
|
||||
| Status overview (`amy status`) — every account, current pin, signer type + can-sign, per-account Marmot/Cashu/alias/cursor footprint, shared event-store size | ✅ | `StatusCommand`. Cross-account, read-only, metadata-only (no keychain prompt, no network). Store stats via shared `StoreStats`. |
|
||||
| Relay config — every relay-list bucket (nip65 10002 via `outbox`/`inbox`/`nip65` nouns with spec read/write merge, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — noun-first `relay <noun> add/remove/set/clear/list` + fan-out `relay add/remove` + publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. |
|
||||
| MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` |
|
||||
| Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` |
|
||||
|
||||
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.cli.commands.PublishCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.StatusCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.StoreCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.SyncCommand
|
||||
@@ -180,6 +181,14 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
return UseCommand.run(tail)
|
||||
}
|
||||
|
||||
// `status` is a cross-account, read-only overview of everything on
|
||||
// disk under ~/.amy/. Like `use`, it must work regardless of how many
|
||||
// accounts exist (zero, one, or many), so it dispatches before account
|
||||
// resolution rather than through the single-account DataDir path.
|
||||
if (head == "status") {
|
||||
return StatusCommand.run(tail)
|
||||
}
|
||||
|
||||
// Stateless local primitives (nak-style army-knife verbs). They operate
|
||||
// purely on their arguments — no identity, no relays, no `~/.amy/` — so
|
||||
// they dispatch before account resolution and work with zero state.
|
||||
@@ -370,6 +379,9 @@ private fun printUsage() {
|
||||
| use NAME pin NAME as the active account
|
||||
| use --clear remove the pin
|
||||
| use print current pin + available accounts
|
||||
| status read-only overview of every account, signer
|
||||
| type, local Marmot/Cashu state, and the shared
|
||||
| event store (no keychain prompt, no network)
|
||||
|
|
||||
|Output:
|
||||
| Default: human-readable text on stdout.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli
|
||||
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.path.exists
|
||||
|
||||
/**
|
||||
* Read-only introspection of a file-backed Nostr event store on disk.
|
||||
*
|
||||
* Pure filesystem walk — no relay traffic, no writer lock, no [Context].
|
||||
* Shared by `amy store stat` (full detail) and `amy status` (a compact
|
||||
* roll-up alongside the account overview).
|
||||
*/
|
||||
data class StoreStats(
|
||||
val events: Long,
|
||||
/** Per-kind event counts derived from `idx/kind/<k>/`, sorted by kind string. */
|
||||
val byKind: Map<String, Long>,
|
||||
val diskBytes: Long,
|
||||
/** Oldest / newest event file mtime, in unix seconds. Null on an empty store. */
|
||||
val oldestAt: Long?,
|
||||
val newestAt: Long?,
|
||||
val root: Path,
|
||||
) {
|
||||
val distinctKinds: Int get() = byKind.size
|
||||
|
||||
companion object {
|
||||
/** Compute stats for the store rooted at [storeRoot]. Missing dir → all-zero. */
|
||||
fun of(storeRoot: Path): StoreStats {
|
||||
if (!storeRoot.exists()) {
|
||||
return StoreStats(0, emptyMap(), 0L, null, null, storeRoot.toAbsolutePath())
|
||||
}
|
||||
|
||||
val eventsRoot = storeRoot.resolve("events")
|
||||
var count = 0L
|
||||
var oldest: Long? = null
|
||||
var newest: Long? = null
|
||||
if (Files.isDirectory(eventsRoot)) {
|
||||
Files.walk(eventsRoot).use { stream ->
|
||||
for (p in stream) {
|
||||
if (!Files.isRegularFile(p)) continue
|
||||
if (!p.fileName.toString().endsWith(".json")) continue
|
||||
count++
|
||||
val mt =
|
||||
try {
|
||||
Files.getLastModifiedTime(p).to(TimeUnit.SECONDS)
|
||||
} catch (_: IOException) {
|
||||
continue
|
||||
}
|
||||
val o = oldest
|
||||
if (o == null || mt < o) oldest = mt
|
||||
val n = newest
|
||||
if (n == null || mt > n) newest = mt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Histogram from idx/kind/<k>/ — for a healthy store this is
|
||||
// exactly one entry per (kind, event), so summing matches `count`.
|
||||
// Mismatch points at index drift; run `amy store scrub` to fix.
|
||||
val kindRoot = storeRoot.resolve("idx/kind")
|
||||
val byKind = sortedMapOf<String, Long>()
|
||||
if (Files.isDirectory(kindRoot)) {
|
||||
Files.list(kindRoot).use { stream ->
|
||||
for (kindDir in stream) {
|
||||
if (!Files.isDirectory(kindDir)) continue
|
||||
val n = Files.list(kindDir).use { it.count() }
|
||||
byKind[kindDir.fileName.toString()] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return StoreStats(
|
||||
events = count,
|
||||
byKind = byKind,
|
||||
diskBytes = walkSize(storeRoot),
|
||||
oldestAt = oldest,
|
||||
newestAt = newest,
|
||||
root = storeRoot.toAbsolutePath(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun walkSize(root: Path): Long {
|
||||
if (!Files.exists(root)) return 0L
|
||||
var total = 0L
|
||||
Files.walk(root).use { stream ->
|
||||
for (p in stream) {
|
||||
if (!Files.isRegularFile(p)) continue
|
||||
total +=
|
||||
try {
|
||||
Files.size(p)
|
||||
} catch (_: IOException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.RunState
|
||||
import com.vitorpamplona.amethyst.cli.StoreStats
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentityFile
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `amy status` — a single at-a-glance overview of everything amy is
|
||||
* holding on disk under `~/.amy/`. Built for the returning user: "I
|
||||
* haven't run this in months — what accounts do I have, which one is
|
||||
* active, can they still sign, and how big is the local database?"
|
||||
*
|
||||
* Cross-account by design, so it dispatches *before* account resolution
|
||||
* (like `use`) and never fails on "zero accounts" or "ambiguous account".
|
||||
* It is strictly read-only and metadata-only: it parses the on-disk
|
||||
* `identity.json` / `state.json` / `aliases.json` and walks the shared
|
||||
* event store, but it never unlocks a private key (no keychain prompt,
|
||||
* no NIP-49 passphrase) and never touches the network.
|
||||
*
|
||||
* Per account it reports the npub, how the key is stored (local keychain
|
||||
* / ncryptsec / plaintext, a NIP-46 bunker, or read-only), whether it can
|
||||
* sign, and the local footprint that account has accumulated: aliases,
|
||||
* Marmot groups, a published KeyPackage bundle, a Cashu wallet, and the
|
||||
* sync cursors that tell catch-up commands where they left off.
|
||||
*/
|
||||
object StatusCommand {
|
||||
fun run(tail: Array<String>): Int {
|
||||
// `status` takes no positional args; tolerate an accidental one
|
||||
// rather than erroring — it's a read-only inspection command.
|
||||
val rootBase = DataDir.DEFAULT_ROOT
|
||||
|
||||
val currentPin =
|
||||
File(rootBase, DataDir.CURRENT_MARKER_NAME)
|
||||
.takeIf { it.isFile }
|
||||
?.readText()
|
||||
?.trim()
|
||||
?.ifEmpty { null }
|
||||
|
||||
val accountNames = DataDir.listAccounts(rootBase)
|
||||
val accounts = accountNames.map { accountRow(File(rootBase, it), it, it == currentPin) }
|
||||
|
||||
// The event store is shared across every account.
|
||||
val store = StoreStats.of(File(rootBase, "shared/events-store").toPath())
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"root" to rootBase.absolutePath,
|
||||
"current" to currentPin,
|
||||
"account_count" to accounts.size,
|
||||
"accounts" to accounts,
|
||||
"store" to
|
||||
mapOf(
|
||||
"events" to store.events,
|
||||
"distinct_kinds" to store.distinctKinds,
|
||||
"disk_bytes" to store.diskBytes,
|
||||
"oldest_at" to store.oldestAt,
|
||||
"newest_at" to store.newestAt,
|
||||
"root" to store.root.toString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun accountRow(
|
||||
accountRoot: File,
|
||||
name: String,
|
||||
isCurrent: Boolean,
|
||||
): Map<String, Any?> {
|
||||
val identity = readIdentity(File(accountRoot, "identity.json"))
|
||||
val signer = classifySigner(identity)
|
||||
|
||||
val marmotGroups =
|
||||
File(accountRoot, "marmot/groups")
|
||||
.listFiles { f -> f.name.endsWith(".state") }
|
||||
?.size ?: 0
|
||||
val hasKeyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile
|
||||
val hasCashuWallet = File(accountRoot, "cashu.json").isFile
|
||||
val aliasCount = readAliases(File(accountRoot, "aliases.json")).size
|
||||
val runState = readRunState(File(accountRoot, "state.json"))
|
||||
|
||||
// LinkedHashMap so the text renderer prints fields in this order.
|
||||
val row = LinkedHashMap<String, Any?>()
|
||||
row["name"] = name
|
||||
row["current"] = isCurrent
|
||||
row["npub"] = identity?.npub
|
||||
row["hex"] = identity?.pubKeyHex
|
||||
row["signer"] = signer.kind
|
||||
row["key_storage"] = signer.storage
|
||||
row["can_sign"] = signer.canSign
|
||||
if (signer.bunkerRelays != null) row["bunker_relays"] = signer.bunkerRelays
|
||||
row["aliases"] = aliasCount
|
||||
row["marmot_groups"] = marmotGroups
|
||||
row["key_package_published"] = hasKeyPackage
|
||||
row["cashu_wallet"] = hasCashuWallet
|
||||
row["dm_cursor_at"] = runState.giftWrapSince
|
||||
row["marmot_group_cursors"] = runState.groupSince.size
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* How this account can sign, derived purely from the on-disk
|
||||
* [IdentityFile] — never resolves the secret itself.
|
||||
* - `local` — an on-device private key ([storage] says where).
|
||||
* - `bunker` — a NIP-46 remote signer ([bunkerRelays] lists it).
|
||||
* - `read-only` — imported from an npub/nprofile/NIP-05; cannot sign.
|
||||
*/
|
||||
private data class SignerInfo(
|
||||
val kind: String,
|
||||
val storage: String?,
|
||||
val canSign: Boolean,
|
||||
val bunkerRelays: List<String>?,
|
||||
)
|
||||
|
||||
private fun classifySigner(identity: IdentityFile?): SignerInfo {
|
||||
if (identity == null) return SignerInfo("unknown", null, false, null)
|
||||
identity.bunker?.let { bunker ->
|
||||
return SignerInfo("bunker", secretStorageLabel(identity.secret), true, bunker.relays)
|
||||
}
|
||||
val storage = secretStorageLabel(identity.secret)
|
||||
return when {
|
||||
identity.secret != null -> SignerInfo("local", storage, true, null)
|
||||
// Pre-secret-store data-dirs kept the key inline; still signable.
|
||||
identity.privKeyHex != null || identity.nsec != null -> SignerInfo("local", "legacy-plaintext", true, null)
|
||||
else -> SignerInfo("read-only", null, false, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun secretStorageLabel(secret: IdentitySecret?): String? =
|
||||
when (secret) {
|
||||
is IdentitySecret.Keychain -> "keychain:${secret.backend}"
|
||||
is IdentitySecret.Ncryptsec -> "ncryptsec"
|
||||
is IdentitySecret.Plaintext -> "plaintext"
|
||||
null -> null
|
||||
}
|
||||
|
||||
private fun readIdentity(file: File): IdentityFile? = if (file.isFile) runCatching { Output.mapper.readValue<IdentityFile>(file.readText()) }.getOrNull() else null
|
||||
|
||||
private fun readAliases(file: File): Map<String, String> = if (file.isFile) runCatching { Output.mapper.readValue<Map<String, String>>(file.readText()) }.getOrElse { emptyMap() } else emptyMap()
|
||||
|
||||
private fun readRunState(file: File): RunState = if (file.isFile) runCatching { Output.mapper.readValue<RunState>(file.readText()) }.getOrElse { RunState() } else RunState()
|
||||
}
|
||||
@@ -22,14 +22,12 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.StoreStats
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.path.exists
|
||||
|
||||
/**
|
||||
* `amy store <stat|sweep-expired|scrub|compact>` — direct introspection
|
||||
@@ -69,70 +67,15 @@ object StoreCommands {
|
||||
)
|
||||
|
||||
private fun stat(dataDir: DataDir): Int {
|
||||
val storeRoot = dataDir.eventsDir.toPath()
|
||||
if (!storeRoot.exists()) {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"events" to 0,
|
||||
"by_kind" to emptyMap<String, Long>(),
|
||||
"disk_bytes" to 0L,
|
||||
"oldest_at" to null,
|
||||
"newest_at" to null,
|
||||
"root" to storeRoot.toAbsolutePath().toString(),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
val eventsRoot = storeRoot.resolve("events")
|
||||
var count = 0L
|
||||
var oldest: Long? = null
|
||||
var newest: Long? = null
|
||||
if (Files.isDirectory(eventsRoot)) {
|
||||
Files.walk(eventsRoot).use { stream ->
|
||||
for (p in stream) {
|
||||
if (!Files.isRegularFile(p)) continue
|
||||
if (!p.fileName.toString().endsWith(".json")) continue
|
||||
count++
|
||||
val mt =
|
||||
try {
|
||||
Files.getLastModifiedTime(p).to(TimeUnit.SECONDS)
|
||||
} catch (_: IOException) {
|
||||
continue
|
||||
}
|
||||
val o = oldest
|
||||
if (o == null || mt < o) oldest = mt
|
||||
val n = newest
|
||||
if (n == null || mt > n) newest = mt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Histogram from idx/kind/<k>/ — for a healthy store this is
|
||||
// exactly one entry per (kind, event), so summing matches `count`.
|
||||
// Mismatch points at index drift; run `amy store scrub` to fix.
|
||||
val kindRoot = storeRoot.resolve("idx/kind")
|
||||
val byKind = sortedMapOf<String, Long>()
|
||||
if (Files.isDirectory(kindRoot)) {
|
||||
Files.list(kindRoot).use { stream ->
|
||||
for (kindDir in stream) {
|
||||
if (!Files.isDirectory(kindDir)) continue
|
||||
val n = Files.list(kindDir).use { it.count() }
|
||||
byKind[kindDir.fileName.toString()] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val diskBytes = walkSize(storeRoot)
|
||||
|
||||
val stats = StoreStats.of(dataDir.eventsDir.toPath())
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"events" to count,
|
||||
"by_kind" to byKind,
|
||||
"disk_bytes" to diskBytes,
|
||||
"oldest_at" to oldest,
|
||||
"newest_at" to newest,
|
||||
"root" to storeRoot.toAbsolutePath().toString(),
|
||||
"events" to stats.events,
|
||||
"by_kind" to stats.byKind,
|
||||
"disk_bytes" to stats.diskBytes,
|
||||
"oldest_at" to stats.oldestAt,
|
||||
"newest_at" to stats.newestAt,
|
||||
"root" to stats.root.toString(),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
@@ -220,23 +163,6 @@ object StoreCommands {
|
||||
}
|
||||
}
|
||||
|
||||
private fun walkSize(root: Path): Long {
|
||||
if (!Files.exists(root)) return 0L
|
||||
var total = 0L
|
||||
Files.walk(root).use { stream ->
|
||||
for (p in stream) {
|
||||
if (!Files.isRegularFile(p)) continue
|
||||
total +=
|
||||
try {
|
||||
Files.size(p)
|
||||
} catch (_: IOException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private fun countEntries(dir: Path): Long {
|
||||
if (!Files.isDirectory(dir)) return 0L
|
||||
return Files.list(dir).use { it.count() }
|
||||
|
||||
Reference in New Issue
Block a user