@effected/app

中文文档
0.33.0发布于 2026/11/13MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 8343

The application control plane for Effect: one layer wiring XDG-namespaced directories, a migrated SQLite store, a TTL cache and a config file to the same place.

原始使用文档
文档目录15 节
  1. Why @effected/app
  2. Install
  3. Quick start
  4. Where the files land
  5. Wiring the flag to the layer
  6. Ensure before open
  7. Memoization: bind the layer to a const
  8. Databases per command: directories everywhere
  9. More than one store
  10. Where is the file?
  11. Testing: one line, no platform package
  12. Errors
  13. Telemetry goes at the app edge
  14. Features
  15. License

@effected/app

npm License: MIT Node.js %3E%3D24.11.0 TypeScript 7.0

The application control plane for Effect. App.layer gives an application its XDG-namespaced directories, a migrated SQLite state database, a TTL cache and — through AppConfig.layer — a config file, all pointed at the same place, with the namespace typed exactly once. It is a composition over @effected/xdg, @effected/store and @effected/config-file, and nothing else.

Pre-1.0.0. This package is part of the @effected/* kit, built on stable Effect v4 (effect ~4.1.0) and still in 0.x development. Stable Effect makes a kit 1.0.0 possible, not automatic. To keep your effect and @effect/* versions on the line the kit is built and tested against, install @effected/pnpm-plugin-effected.

Stability: unstable. This package's API surface is not yet considered complete and may change across 0.x releases. Pin an exact version — even a package marked stable before 1.0.0 can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.

Why @effected/app

Every application built on this kit writes the same forty lines of wiring, and it is the kind of wiring that looks right and is wrong. @effected/xdg resolves where a namespace's directories are. @effected/store opens a database at a path. Between them sits an ordering nobody thinks about until it bites: the store layer cannot create a missing parent directory, so the directory must be ensured before the store layer is built, or every first run fails to open its database.

The other half is the namespace itself. An application names it for its directories, then names it again for its config file, and the two strings drift — "myapp" here, "my-app" there — and now it reads config from a directory nothing else in the process ever writes to.

This package is the composition that gets both right, and that is all it is. It owns no domain logic: no service, no schema, no error class, and it re-exports nothing. The entire surface is layer factories, one config preset and one type alias. If a change here wants a Context.Service, that is the signal the change belongs in one of the three packages beneath it.

No library or package may depend on @effected/app — but the application at the top of the graph is exactly its intended consumer. A library taking an application control plane as a dependency would drag a SQLite driver into its own consumers' trees. This is the package an application composes at its edge — and the only one in the kit whose docs show where OpenTelemetry goes.

Install

npm install @effected/app effect @effected/xdg @effected/store @effected/config-file
pnpm add @effected/app effect @effected/xdg @effected/store @effected/config-file

For the test layer at @effected/app/testing, add @effected/memfs as a dev dependency — it is an optional peer that only that entry point imports:

pnpm add -D @effected/memfs

Requires Node.js >=24.11.0.

All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() — including tools that resolve in CJS mode — fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED rather than loading a CJS build that does not exist. Import from an ES module.

There are no runtime dependencies. effect v4 is a peer dependency, and so are @effected/xdg, @effected/store and @effected/config-file — which is load-bearing rather than incidental. Each of the three appears in this package's public signature types, so a second copy of any of them in your graph would mint two distinct service tags for one concept and the layer would silently fail to satisfy the requirement. Single copies are the point, and that is exactly what a peer declares. Package managers that install peers automatically will pull them in; add them to your manifest explicitly if yours does not.

The package is integrated tier by inheritance, not by anything it does: @effected/store reaches @effect/sql-sqlite-node, and that propagates. It performs no IO the three packages beneath it do not already perform.

Creating directories needs a FileSystem and a Path implementation, provided once at the edge — from @effect/platform-node on Node.

Quick start

A config file, a cache and a state database, over one platform import:

import { App, AppConfig } from "@effected/app";
import { ConfigFile, JsonCodec } from "@effected/config-file";
import { Cache, Store } from "@effected/store";
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { Effect, Layer, Schema } from "effect";

class Settings extends Schema.Class<Settings>("Settings")({
  registry: Schema.String,
  concurrency: Schema.Number,
}) {}
class SettingsFile extends ConfigFile.Service<SettingsFile, Settings>()("myapp/Settings") {}

const migrations = [
  { id: 1, name: "runs", up: (sql) => sql`CREATE TABLE runs (id TEXT PRIMARY KEY, at TEXT)` },
];

// Bound once, to a const — see Memoization below. This is the whole control plane:
// XDG dirs for "myapp", store.db in the state dir, cache.db in the cache dir.
const AppLive = App.layer({ namespace: "myapp", store: { migrations }, cache: { maxEntries: 500 } });

const ConfigLive = AppConfig.layer(SettingsFile, {
  filename: "config.json", // no namespace: it comes from AppLive's AppDirs
  schema: Settings,
  codec: JsonCodec,
});

const MainLive = ConfigLive.pipe(
  Layer.provideMerge(AppLive),
  Layer.provide(NodeServices.layer), // the one place a platform is named
);

const main = Effect.gen(function* () {
  const settings = yield* (yield* SettingsFile).load;
  const store = yield* Store;
  const cache = yield* Cache;

  yield* store.client`INSERT INTO runs (id, at) VALUES (${crypto.randomUUID()}, datetime())`;
  yield* cache.set({ key: "last-registry", value: new TextEncoder().encode(settings.registry) });
});

NodeRuntime.runMain(main.pipe(Effect.provide(MainLive)));

Four services, one platform import, one namespace typed once, and every failure on the typed channel. That is the whole package.

Where the files land

App.layer ensures each directory before it opens anything in it. With namespace: "myapp" and the default native: false, the XDG rules apply on every platform:

WhatDirectoryDefault file
State database$XDG_STATE_HOME/myapp — ~/.local/state/myappstore.db
Cache database$XDG_CACHE_HOME/myapp — ~/.cache/myappcache.db
Config file$XDG_CONFIG_HOME/myapp — ~/.config/myappyour filename

AppOptions is @effected/xdg's AppDirsOptions straight through — namespace, native, fallbackDir and dirs mean there exactly what they mean here, five-rung precedence ladder included, and this package re-documents none of it.

AppStore and AppCache take a directory ("state", "data" or "cache"; defaults as above) and an optional relative subdir under it, created with mkdir -p before the database opens — so { directory: "data", subdir: "projects/abc123", filename: "data.db" } lands at ~/.local/share/myapp/projects/abc123/data.db. A subdir that cannot be created fails on the same typed AppDirsError as the directory itself; because of that mkdir, the database layers need FileSystem as well as AppDirs and Path.

Every filename takes a single path component, and every subdir component obeys the same rule (no empty component, no . or .., no leading /, no \). Both are wiring, so a bad one dies rather than failing typed. A subdir derived from runtime data — a per-project key from a path — must therefore come from a derivation that can only produce valid components, such as a hash:

import { createHash } from "node:crypto";

const subdir = createHash("sha256").update(projectRoot).digest("hex").slice(0, 16);
``` An empty name, one containing a separator, or `.` / `..` would escape the namespace directory, so it dies at layer construction: it can only come from code, never from user input.

## The namespace is typed once

`AppConfig.layer` takes no namespace. It reads one from the ambient `AppDirs` service at layer build time, so the namespace is named exactly once — in `App.layer` — and the two-strings drift cannot happen. Anything that can be derived is not asked for.

What it does take is a **codec, required**, never inferred from the filename's extension. Inferring one would hard-code a *format* choice into a composition layer, which is not this package's decision to make; and the named import is also what keeps the other three parsing engines out of your bundle.

`AppConfig` lives in its own module and reaches `@effected/xdg` and `@effected/config-file` **only** — never `@effected/store`. An application that wants XDG-placed config files and no database imports `AppConfig` alone, and no SQLite driver enters its graph. `App`, `AppStore` and `AppCache` are the exports that reach a database, and keeping the two graphs apart is why there is no `App = { … }` namespace object here.

## A `--config` flag, without leaving the preset

Most CLIs have one, and it has to outrank the app's own search path. Pass `resolvers` and it does:

```ts
import { AppConfig } from "@effected/app";
import { ConfigResolver } from "@effected/config-file";
import { TomlCodec } from "@effected/config-file/toml";

const ConfigLive = AppConfig.layer(SettingsFile, {
 filename: "settings.toml",
 schema: Settings,
 codec: TomlCodec,
 // Composed AHEAD of the XDG chain, in the order given.
 resolvers: flag === undefined ? [] : [ConfigResolver.explicitPath(flag)],
});

The XDG search path and the native probe stay behind whatever you prepend, so absent the flag nothing changes. ConfigResolver.staticDir covers a --config naming a directory, and ConfigResolver.upwardWalk a project-local file found by walking up from the cwd.

Two properties to be deliberate about. A resolver that finds nothing falls through: every ConfigResolver's error channel is never by contract, so a --config pointing at a file that does not exist quietly loads the XDG config instead. If that must be an error, check the path before you build the layer — discovery cannot make that distinction for you. And the save path is unaffected: save still writes to the app's own config directory, so writing back to the file a flag named is write(value, path), which takes the path explicitly.

Wiring the flag to the layer

A layer is built before a CLI parses anything, so the parsed --config has to reach AppConfig.layer somehow. effect/cli has two ways, and neither needs an Effect.provide inside the handler.

For a single command, Command.provide takes a function of the parsed input, not just a finished layer:

Command.make("validate", { config: Flag.string("config").pipe(Flag.optional) }, () =>
 Effect.gen(function* () {
  const settings = yield* (yield* SettingsFile).load; // just requires the service
 }),
).pipe(Command.provide(({ config }) => makeConfigLive(Option.getOrUndefined(config))));

For several subcommands sharing one --config, make the flag a global setting — GlobalFlag.setting returns a Context.Service, so the parsed value can sit in the layer's R and the layer attaches once at the root:

const ConfigFlag = GlobalFlag.setting("config")({ flag: Flag.string("config").pipe(Flag.optional) });

const ConfigLive = Layer.unwrap(Effect.map(ConfigFlag, (f) => makeConfigLive(Option.getOrUndefined(f))));

const root = Command.make("myapp", {}, () => Effect.void).pipe(
 Command.withSubcommands([validate, sync]),
 Command.provide(ConfigLive), // one call site, every subcommand
 Command.withGlobalFlags([ConfigFlag]),
);

The flag is then accepted on either side of the subcommand name.

A chain that needs the XDG resolvers somewhere other than last — or not at all — has outgrown the preset. Compose ConfigFile.layer from @effected/config-file directly and order the whole chain yourself; it costs you only the defaultPath and ambient-namespace wiring this preset does for free.

Its native option defaults to true — the opposite of AppDirsOptions.native, and the asymmetry is deliberate. Creating a native directory commits an application to a location, so it is opt-in; probing one for a config file the user already put there costs a stat that finds nothing, so it is opt-out. Reading ~/Library/Application Support is a courtesy; writing there uninvited is not.

Ensure before open

The one thing this package actually claims. AppStore.layer yields AppDirs, runs ensureState, joins the path, and only then hands it to Store.layerSqlite; AppCache.layer does the same over ensureCache. That ordering makes the directory exist before the open — AppDirs.ensure* is a mkdir -p on a typed AppDirsError channel, so "the state directory could not be created" arrives as a recoverable boundary failure rather than a die.

Nothing is orDied to make a signature tidier. A regression test pins an unwritable ancestor to a typed failure and watches for a die.

App.layer always provides both databases. An application that wants only one composes AppStore.layer or AppCache.layer directly and never opens the other file. The honest consequence: passing no cache options still opens cache.db, because every CacheOptions field is optional and absence means defaults, not absence.

Memoization: bind the layer to a const

Every export here is a parameterized layer factory, and Effect memoizes layers by reference. Each call to App.layer(…) builds a new one.

const AppLive = App.layer({ namespace: "myapp", store: { migrations } }); // once

Call the factory inline at two provide sites and you open two databases: two connections onto one file, two migration ledgers, and two independent CacheEvent PubSubs whose subscribers each see half the events. This is the package where an application is most likely to compose the same layer twice, which is why the rule is here and not in a footnote.

Databases per command: directories everywhere

Building App.layer opens and migrates both databases, eagerly — store.db and cache.db exist the moment the layer is built, whether or not the program touches them. For a long-running service that is what you want. For a CLI it is not: provide App.layer at the entry point and every command creates and migrates both files, --help-adjacent paths included, and a command that deletes the app's files ends up deleting a database its own process holds open.

App.layerDirs is the directories half on its own — Xdg and AppDirs for one namespace, XdgEnvError on the error channel, FileSystem and Path in R, and no database opened. Provide it once at the edge, bind each database layer once at module scope, and attach it with Command.provide only on the commands that use it:

import { App, AppStore } from "@effected/app";
import { CliRuntime } from "@effected/cli-kit";
import { Store } from "@effected/store";
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { Effect, Layer } from "effect";
import { Command } from "effect/cli";

// Module scope, bound once: built once per provided layer graph.
const StoreLive = AppStore.layer({ migrations });

// Opens store.db — only when `history` runs.
const history = Command.make("history", {}, () =>
  Effect.gen(function* () {
    const store = yield* Store;
    yield* store.client`SELECT id FROM runs`;
  }),
).pipe(Command.provide(StoreLive));

// Opens no database at all.
const where = Command.make("where", {}, () => Effect.void);

const cli = Command.make("myapp").pipe(Command.withSubcommands([history, where]));

// Directories at the edge, once; AppDirs satisfies StoreLive's requirement.
const PlatformLive = App.layerDirs({ namespace: "myapp" }).pipe(Layer.provideMerge(NodeServices.layer));

NodeRuntime.runMain(CliRuntime.main(Command.run(cli, { version: "1.0.0" }), { platform: PlatformLive }));

A module-scope binding such as StoreLive stays one connection however many composites reuse it inside one provided layer graph — Layer.mergeAll(StoreLive, …) beside Repo.pipe(Layer.provide(StoreLive)) — because a layer graph memoises by reference, and an Effect.provide nested inside another reuses what the enclosing one already built. Provides that are not nested — one after another, or side by side under a common parent — each build it, and so open the database, again.

Command.provide also accepts a function of the command's parsed input, (input) => Layer, for a database whose options depend on a flag. Bind anything that does not depend on the input outside that function — a layer built inside it is a new reference on every call.

More than one store

AppStore.layer and AppCache.layer provide the kit's single Store and Cache services. An application that keeps a second database — a registry beside its run history, a separate cache for large objects — declares its own service key over the same shape and builds it with layerAs:

import { AppCache, AppStore } from "@effected/app";
import type { CacheShape, StoreShape } from "@effected/store";
import { Context, Layer } from "effect";

class RegistryStore extends Context.Service<RegistryStore, StoreShape>()("myapp/RegistryStore") {}
class TarballCache extends Context.Service<TarballCache, CacheShape>()("myapp/TarballCache") {}

// Every one bound once, to a const.
const StoreLive = AppStore.layer({ migrations });
const RegistryStoreLive = AppStore.layerAs(RegistryStore, { filename: "registry.db", migrations: registryMigrations });
const TarballCacheLive = AppCache.layerAs(TarballCache, { filename: "tarballs.db", maxEntries: 200 });

const DatabasesLive = Layer.mergeAll(StoreLive, RegistryStoreLive, TarballCacheLive);

Each keyed layer runs the same ensure-before-open glue, in the same default directory as its primary — state for stores, cache for caches, unless directory / subdir say otherwise — and outputs only its own key: the Store or Cache it builds internally never leaks, so it composes beside the primary without shadowing it. Each file has its own migrations and its own ledger.

  • filename is required. A default of store.db or cache.db would land a keyed layer silently on the primary's file — two connections and two migration ledgers on one database.
  • The key's service type must be StoreShape or CacheShape. An incompatible shape is a compile error, and so is one that adds members (StoreShape & { … }), reported as an argument not assignable to never because this layer cannot supply them. The check cannot see through method-syntax parameter bivariance: a member redeclared as a method with a wider parameter still compiles.
  • Bind every keyed layer to a const. Each layerAs(…) call is a new layer, and the memoization trap above applies to each one separately: two inline calls with the same key open the file twice.

The keyed layers need AppDirs, Path and FileSystem, which is exactly what App.layerDirs plus the platform provides — they slot into the per-command shape above unchanged.

Every AppStore / AppCache option beyond the path is @effected/store's, passed straight through: client (driver options such as busyTimeout and disableWAL — the place for per-connection settings, never a migration), checkpointOnClose, onConnect (a per-connection hook run before the ledger, outside any transaction), and on stores adoptMigratorLedger and mirrorMigratorLedger, which move a database previously migrated by effect/sql's Migrator onto Store without re-running its history and keep that ledger current for older versions (see @effected/store's README for the rules). A database at an absolute or host-chosen path is not an app concern: use Store.layerSqliteAs / Cache.layerSqliteAs from @effected/store directly.

Where is the file?

A program that reports or persists the database path — a doctor command, a "your data lives at" line — derives it with AppStore.location(options) / AppCache.location(options), passing the same options it passes the layer. It resolves the absolute path from the ambient AppDirs without creating anything (R is AppDirs | Path), and it is the very derivation the layers use, so the reported path and the opened file cannot disagree:

const options = { filename: "registry.db", directory: "data", migrations } as const;

const RegistryStoreLive = AppStore.layerAs(RegistryStore, options);
const where = AppStore.location(options); // Effect<string, never, AppDirs | Path>

Testing: one line, no platform package

AppTest.layer, from the @effected/app/testing entry point, is the hermetic control plane — fixed XDG paths, :memory: databases, and an in-memory filesystem from @effected/memfs, all provided by the layer itself. It lives on its own entry point so an application's runtime import graph never loads test code; @effected/memfs is an optional peer that only this entry point needs, so install it as a dev dependency. A consumer's first test needs no platform import at all:

import { AppTest } from "@effected/app/testing";
import { layer } from "@effect/vitest";
import { Effect } from "effect";

layer(AppTest.layer({ namespace: "myapp" }))("app", (it) => {
  it.effect("stores state", () =>
    Effect.gen(function* () {
      // Store and Cache are here, in memory, hermetic.
    }));
});

The filesystem is real, just in memory: every AppDirs.ensure* creates directories on the volume, files seeds it (a config body under /home/test/.config/<namespace>/ is what an AppConfig.layer composed over the test layer loads), and the MemoryFileSystem.Volume service reads back what the program wrote. FileSystem and Path come out of the layer beside the four services, so AppConfig.layer composes over it with a single Layer.provide on the same volume. The databases are the exception — they are :memory: here and open through the native SQLite binding under App.layer, so do not compose AppStore.layer or AppCache.layer over the test layer — on the synthetic defaults the binding fails to open a path that does not exist on the host (a StoreError), and with real paths it would write onto the host disk; an end-to-end test of App.layer runs against a temp-directory HOME, which is what this package's own integration suite does.

Errors

AppError is a type-only alias — the copy-pasteable union for the catchTags block at the application edge. It defines nothing: every tag in it is raised, and documented, by the package beneath that owns the operation, and each flows through unwrapped. A StoreMigrationError that reaches your handler still carries the migration's id, name and direction.

TagRaised byMeans
XdgEnvError@effected/xdgThe home directory is not set ($HOME, or on Windows %USERPROFILE% then $HOME) — the one environment failure there is.
AppDirsError@effected/xdgA directory could not be created. Check permissions.
StoreError@effected/storeThe state database's own SQL failed — ledger bookkeeping, or the queries around a migration.
StoreMigrationError@effected/storeOne of your migrations failed. Carries direction, id and name.
CacheError@effected/storeA cache operation's SQL failed. A cache is a cache — falling back to the origin is usually right.

Wiring mistakes are defects, not errors: a filename that is not a single path component dies at layer construction, as does a namespace that would escape $HOME. They can only come from code.

Telemetry goes at the app edge

This package emits no spans of its own, deliberately. Every fallible operation in the glue is already spanned by the package that owns it — AppDirs.ensure* by xdg, migrations and every Cache method by store, every ConfigFile method by config-file. A span here would be a span around another package's span.

Every library in this kit is telemetry-agnostic and none of them import @effect/opentelemetry. Applications do, exactly once, at the top:

import { NodeSdk } from "@effect/opentelemetry";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";

const TelemetryLive = NodeSdk.layer(() => ({
  resource: { serviceName: "myapp" },
  spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter()),
}));

const MainLive = ConfigLive.pipe(
  Layer.provideMerge(AppLive),
  Layer.provide(NodeServices.layer),
  Layer.provide(TelemetryLive), // composed once, beneath everything
);

Provide it beneath the stack and every span the kit's packages already emit — directory creation, migrations, cache reads, config loads — arrives at your collector. Nothing in the libraries changes, because a library that chose an exporter for you would be making an application's decision.

Features

  • App.layer — the control plane: Xdg, AppDirs, Store and Cache from one call, with only FileSystem and Path left for the platform layer to supply.
  • App.layerDirs — Xdg and AppDirs alone, opening no database: directories everywhere, databases attached only where they are used.
  • AppTest.layer (from @effected/app/testing) — the same four services plus the in-memory FileSystem, Path and MemoryFileSystem.Volume they run on, hermetic: synthetic XDG paths, :memory: databases, a seedable memfs volume, no platform package required.
  • AppStore.layer / AppCache.layer — the state-directory and cache-directory databases on their own, each ensuring its directory before it opens the file.
  • AppStore.layerAs / AppCache.layerAs — an additional database under a service key you define, with its own file, migrations and ledger.
  • AppConfig.layer — @effected/config-file wired to xdg's resolver chain and save path, with the namespace read from the ambient AppDirs and the codec named by you.
  • AppError — the type-only union of everything the control plane can fail with, for the catchTags block at the edge.

License

MIT

版本与发布记录

@effected/app 共发布 69 个版本,首次发布于 2026/07/17;过去 12 个月发布了 69 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.33.0latest2026/11/13今天
0.32.12026/11/112 天前
0.32.02026/11/102 天前
0.31.32026/11/103 天前
0.31.22026/11/094 天前
0.31.12026/11/067 天前

关于 @effected/app 的常见问题

@effected/app 是什么?

The application control plane for Effect: one layer wiring XDG-namespaced directories, a migrated SQLite store, a TTL cache and a config file to the same place.

如何安装 @effected/app?

在项目目录运行 npm install @effected/app,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(~4.1.0)、@effected/xdg(^0.21.0)、@effected/glob(^0.22.0)、@effected/store(^0.25.0)、@effected/walker(^0.29.0)、@effected/config-file(^0.28.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@effected/app 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@effected/app 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@effected/app 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@effected/app 可以商用吗?

@effected/app 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@effected/app 的下载量有多少?

根据 npm 官方下载统计,@effected/app 在 2026/10/13 – 2026/11/11 这 30 天内被下载 8,343 次,日均约 278 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@effected/app 还在维护吗?

最新版本 0.33.0 发布于 2026/11/13(今天);至今共发布 69 个版本,其中过去 12 个月发布了 69 个;首次发布于 2026/07/17。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。