@mu-cabin/muc-cli 0.6.0 → 0.6.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.
package/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  0.6.0 迁入新版命令契约。要求 **Node.js ≥ 22.16**,使用内置 SQLite,无需独立 Supervisor、Launcher、Keychain、TUI 或客户端。运行时依赖已打包进单个 `dist/muc.mjs`。
6
6
 
7
+ ## 0.6.2 附件下载修复
8
+
9
+ 图片和文件可下载到操作系统允许写入的目录,包括 macOS `/tmp`、共享目录和目录符号链接。路径无效或目标文件已存在时返回明确错误与修复提示,不再误报后台不支持操作。下载仍不覆盖已有文件。
10
+
7
11
  ## 配置和登录
8
12
 
9
13
  默认数据目录为 `~/.muc`;`MUC_CONFIG_DIR` 或全局 `--config-root` 可指定隔离目录。首次部署用 `muc init` 从受保护 stdin 接收管理员提供的租户和端点配置。
@@ -32,12 +36,18 @@ JSON
32
36
 
33
37
  消费程序保存最后一条成功处理的事件游标,重连时通过 `cursor` 继续。`events.list` 用于持久化事件补取。后台离线期间的历史消息通过 `messages.list/search` 查询,不能把本地事件为空当作离线期间没有消息。
34
38
 
39
+ 启动配置错误会直接返回原因,不再仅等待超时。`daemon logs` 返回安全的错误代码、说明和处理提示,不输出原始异常或凭据。
40
+
35
41
  普通命令按需启动 daemon;`daemon status/logs/stop` 不会启动后台。升级可执行文件前先停止相应配置根的 daemon,更新后再启动。被踢下线或主动停止的 profile 保留停止状态,须明确登录或 `runtime resume` 才能恢复。
36
42
 
37
43
  完整参数见 [REFERENCE.md](REFERENCE.md),agent 操作说明见 [skills/muc/SKILL.md](skills/muc/SKILL.md)。文件消息默认不附加尾注;文字和图片仍遵循原有尾注设置。发送结果不明时查询原 delivery/idempotency key,不自动重发。
38
44
 
39
45
  ## 从旧 CLI 迁移
40
46
 
47
+ 0.6.1 支持单账号平铺配置和 `activeProfile + profiles` 多账号配置;后者保留全部 profile,并将默认账号映射为 `selectedProfileId`。不同租户、缺失默认账号或无法解密的记住密码会拒绝迁移并保留原文件。
48
+
49
+ 迁移后会在启动时登记消息数据库中的账号,确保群信息和消息同步可正常落库。
50
+
41
51
  首次启动会识别当前配置根中的旧版 `config.json` / `credentials.json`,保留设备、插槽、账号、token、记住密码和消息尾注,并生成私有的 `*.v1.backup.json` 备份。迁移只处理所选配置根;旧后台仍运行时会拒绝迁移。旧消息日志、assistant 配置和旧版命令不迁入。
42
52
 
43
53
  旧 `send/history/listen/assistant/daemon-*` 命令改用新版 `exec` 和 `daemon` 命令,Skill 合并为 `skills/muc`。此版本不提供 TUI、机器人执行后端和独立升级控制面。不要直接将旧二进制指向迁移后的目录;回退须停止新后台后在独立目录恢复旧备份及原 `remember.salt`。
package/REFERENCE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MUC 命令契约
2
2
 
3
- 本文件描述 0.6.0 的命令契约。旧的 `send/history/daemon-*` 命令已移除。
3
+ 本文件描述 0.6.2 的命令契约。旧的 `send/history/daemon-*` 命令已移除。
4
4
 
5
5
  ## 命令结构
6
6
 
@@ -46,3 +46,5 @@
46
46
  认证错误退出码为 3,禁止访问为 8,配置缺失为 6、配置无效为 7,输入错误为 2;提交结果不明为 75,并带 `automaticReplay:false`。错误的 `hint` 提供可执行的恢复路径。
47
47
 
48
48
  普通文件发送默认不带尾注(包括带说明的文件消息);明确 `--tail` 才添加。文件上传须完成服务端扫描并取得未来有效期,才会进入消息发送;扫描失败不会降级发送过期文件。
49
+
50
+ 附件下载按操作系统权限处理输出目录,支持 `/tmp`、共享目录和目录符号链接;仍不覆盖已有文件。`OUTPUT_PATH_INVALID` 表示路径无效或不可用,`OUTPUT_EXISTS` 表示目标文件已存在。省略 `outputPath` 使用账号私有下载目录。
package/dist/muc.mjs CHANGED
@@ -11279,7 +11279,7 @@ function toErrorEnvelope(error) {
11279
11279
  ok: false,
11280
11280
  error: {
11281
11281
  code: publicErrorCode(error),
11282
- message: error.message,
11282
+ message: outputPathErrorMessage(publicErrorCode(error)) ?? error.message,
11283
11283
  ...errorHint(publicErrorCode(error)) ? { hint: errorHint(publicErrorCode(error)) } : {},
11284
11284
  ...details === void 0 ? {} : { details },
11285
11285
  ...error instanceof CommitOutcomeUnknownError ? { automaticReplay: false } : {}
@@ -11304,16 +11304,23 @@ function safeErrorDetails(value) {
11304
11304
  }
11305
11305
  return Object.keys(safe).length === 0 ? void 0 : Object.freeze(safe);
11306
11306
  }
11307
- var REMOTE_CODES = /* @__PURE__ */ new Set(["AUTH_REQUIRED", "AUTH_EXPIRED", "AUTH_FAILED", "FORBIDDEN", "CONFIG_MISSING", "CONFIG_INVALID", "PROFILE_NOT_FOUND", "PROFILE_EXISTS", "PROFILE_CHANGED", "PROFILE_ACCOUNT_MISMATCH", "RUNTIME_CONFLICT", "RUNTIME_NOT_READY", "INVALID_INPUT", "INVALID_PARAMS", "INVALID_CURSOR", "NOT_FOUND", "UNAVAILABLE", "UNAUTHORIZED", "DEADLINE_EXCEEDED", "IDEMPOTENCY_CONFLICT", "METHOD_NOT_FOUND", "INTERNAL_ERROR"]);
11307
+ var REMOTE_CODES = /* @__PURE__ */ new Set(["OUTPUT_PATH_INVALID", "OUTPUT_EXISTS", "AUTH_REQUIRED", "AUTH_EXPIRED", "AUTH_FAILED", "FORBIDDEN", "CONFIG_MISSING", "CONFIG_INVALID", "PROFILE_NOT_FOUND", "PROFILE_EXISTS", "PROFILE_CHANGED", "PROFILE_ACCOUNT_MISMATCH", "RUNTIME_CONFLICT", "RUNTIME_NOT_READY", "INVALID_INPUT", "INVALID_PARAMS", "INVALID_CURSOR", "NOT_FOUND", "UNAVAILABLE", "UNAUTHORIZED", "DEADLINE_EXCEEDED", "IDEMPOTENCY_CONFLICT", "METHOD_NOT_FOUND", "INTERNAL_ERROR"]);
11308
11308
  function publicErrorCode(error) {
11309
11309
  return error instanceof RemoteRpcError && REMOTE_CODES.has(String(error.remoteCode)) ? String(error.remoteCode) : error.code;
11310
11310
  }
11311
11311
  function errorHint(code) {
11312
+ if (code === "OUTPUT_PATH_INVALID") return "Choose an existing writable directory, or omit outputPath to use private download storage.";
11313
+ if (code === "OUTPUT_EXISTS") return "Choose a new output filename; downloads never overwrite existing files.";
11312
11314
  if (code === "CONFIG_MISSING") return "Run muc init with administrator-provided configuration on protected stdin.";
11313
11315
  if (["AUTH_REQUIRED", "AUTH_EXPIRED", "AUTH_FAILED"].includes(code)) return "Run muc login for this profile in your own terminal; do not paste credentials into chat.";
11314
11316
  if (code === "CONFIG_INVALID") return "Inspect the configuration privately; do not copy credentials into diagnostics.";
11315
11317
  return void 0;
11316
11318
  }
11319
+ function outputPathErrorMessage(code) {
11320
+ if (code === "OUTPUT_PATH_INVALID") return "The attachment output path is invalid or unavailable.";
11321
+ if (code === "OUTPUT_EXISTS") return "The attachment output file already exists.";
11322
+ return void 0;
11323
+ }
11317
11324
 
11318
11325
  // src/cli/io.ts
11319
11326
  var MAX_STDIN_BYTES = 1024 * 1024;
@@ -11956,26 +11963,29 @@ function deriveKey(root) {
11956
11963
  maxmem: 64 * 1024 * 1024
11957
11964
  });
11958
11965
  }
11959
- function encryptRemembered(plaintext, root) {
11966
+ function encryptRemembered(plaintext, root, profile) {
11960
11967
  if (!plaintext) throw new Error("encryptRemembered: empty plaintext");
11961
11968
  const key = deriveKey(root);
11962
11969
  const iv = randomBytes2(IV_BYTES);
11963
11970
  const cipher = createCipheriv("aes-256-gcm", key, iv);
11971
+ if (profile !== void 0) cipher.setAAD(Buffer.from(profile, "utf8"));
11964
11972
  const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
11965
11973
  const tag = cipher.getAuthTag();
11966
- return `v1.${iv.toString("base64")}.${tag.toString("base64")}.${ct.toString("base64")}`;
11974
+ return `${profile === void 0 ? "v1" : "v2"}.${iv.toString("base64")}.${tag.toString("base64")}.${ct.toString("base64")}`;
11967
11975
  }
11968
- function decryptRemembered(blob, root) {
11976
+ function decryptRemembered(blob, root, profile) {
11969
11977
  try {
11970
11978
  if (typeof blob !== "string") return null;
11971
11979
  const parts = blob.split(".");
11972
- if (parts.length !== 4 || parts[0] !== "v1") return null;
11980
+ if (parts.length !== 4 || !["v1", "v2"].includes(parts[0]) || parts[0] === "v2" && profile === void 0) return null;
11981
+ if (!existsSync(saltFile(root))) return null;
11973
11982
  const iv = Buffer.from(parts[1], "base64");
11974
11983
  const tag = Buffer.from(parts[2], "base64");
11975
11984
  const ct = Buffer.from(parts[3], "base64");
11976
11985
  if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) return null;
11977
11986
  const key = deriveKey(root);
11978
11987
  const decipher = createDecipheriv("aes-256-gcm", key, iv);
11988
+ if (parts[0] === "v2") decipher.setAAD(Buffer.from(profile, "utf8"));
11979
11989
  decipher.setAuthTag(tag);
11980
11990
  const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
11981
11991
  return pt.toString("utf8");
@@ -12117,6 +12127,103 @@ var LegacyCredentials = external_exports.object({
12117
12127
  rememberedPwd: external_exports.string().optional()
12118
12128
  });
12119
12129
  var Journal = external_exports.object({ config: NativeProfileDocumentSchema, credentials: CredentialDocumentSchema }).strict();
12130
+ var MultiProfile = LegacyConfig.omit({
12131
+ schemaVersion: true,
12132
+ appKey: true,
12133
+ appId: true,
12134
+ signSecretRest: true,
12135
+ signSecretSecure: true,
12136
+ pwdDesKey: true
12137
+ }).extend({
12138
+ account: external_exports.string().min(1),
12139
+ generation: external_exports.string().uuid(),
12140
+ deviceId: LegacyCredentials.shape.deviceId,
12141
+ deviceName: LegacyCredentials.shape.deviceName,
12142
+ identity: external_exports.object({ uid: external_exports.string().optional(), employeeId: external_exports.string().optional(), name: external_exports.string().optional() }).optional()
12143
+ });
12144
+ var MultiConfig = external_exports.object({
12145
+ schemaVersion: external_exports.literal(1),
12146
+ activeProfile: NativeProfileIdSchema,
12147
+ profiles: external_exports.record(NativeProfileIdSchema, MultiProfile)
12148
+ });
12149
+ var MultiCredentials = external_exports.object({ schemaVersion: external_exports.literal(1), profiles: external_exports.record(NativeProfileIdSchema, external_exports.object({
12150
+ tenant: LegacyConfig.pick({ appKey: true, appId: true, signSecretRest: true, signSecretSecure: true, pwdDesKey: true, savedAt: true }).optional(),
12151
+ session: LegacyCredentials.omit({ uid: true, empId: true, name: true, deviceId: true, deviceName: true }).extend({
12152
+ appVersion: external_exports.string().min(1),
12153
+ rememberedPassword: external_exports.string().min(1).optional()
12154
+ }).optional()
12155
+ })) });
12156
+ function convertMulti(root, raw, secrets) {
12157
+ const old = MultiConfig.parse(raw);
12158
+ const creds = MultiCredentials.parse(secrets ?? { schemaVersion: 1, profiles: {} });
12159
+ if (!Object.hasOwn(old.profiles, old.activeProfile)) throw new CliConfigError("CONFIG_INVALID", "Legacy default profile does not exist. Original files are unchanged.");
12160
+ if (Object.keys(creds.profiles).some((id) => !Object.hasOwn(old.profiles, id))) throw new CliConfigError("CONFIG_INVALID", "Legacy credentials contain an unknown profile. Original files are unchanged.");
12161
+ const profiles = {};
12162
+ const credentials = { schemaVersion: 2, profiles: {} };
12163
+ for (const [id, oldProfile] of Object.entries(old.profiles)) {
12164
+ const profile = createNativeProfile({
12165
+ id,
12166
+ endpoints: {
12167
+ restOrigin: new URL(oldProfile.restHost).origin,
12168
+ imHost: oldProfile.imHost,
12169
+ imPort: oldProfile.imPort,
12170
+ fileHost: oldProfile.fileHost,
12171
+ filePort: oldProfile.filePort
12172
+ },
12173
+ slot: slotFromChatOs(oldProfile.defaultChatOs ?? "iPad"),
12174
+ cliSignature: oldProfile.messageTail === void 0 ? { mode: "builtin" } : oldProfile.messageTail === "" ? { mode: "disabled" } : { mode: "custom", text: oldProfile.messageTail }
12175
+ }, { generation: oldProfile.generation, now: () => oldProfile.savedAt });
12176
+ profile.device = { id: oldProfile.deviceId, name: oldProfile.deviceName };
12177
+ profile.identity = {
12178
+ account: oldProfile.account.toUpperCase(),
12179
+ ...oldProfile.identity?.employeeId ? { employeeId: oldProfile.identity.employeeId } : {},
12180
+ ...oldProfile.identity?.name ? { displayName: oldProfile.identity.name } : {}
12181
+ };
12182
+ profiles[id] = NativeProfileSchema.parse(profile);
12183
+ const secret = creds.profiles[id];
12184
+ if (secret?.tenant) {
12185
+ const t = secret.tenant;
12186
+ const tenant = NativeTenantSecretSchema.parse({
12187
+ schemaVersion: 2,
12188
+ appKey: t.appKey,
12189
+ appId: t.appId,
12190
+ signSecretRest: t.signSecretRest,
12191
+ signSecretSecure: t.signSecretSecure,
12192
+ passwordKey: t.pwdDesKey,
12193
+ savedAtMs: t.savedAt
12194
+ });
12195
+ const { savedAtMs: _, ...identity } = tenant;
12196
+ const { savedAtMs: __, ...existing } = credentials.tenant ?? tenant;
12197
+ if (JSON.stringify(identity) !== JSON.stringify(existing)) throw new CliConfigError("CONFIG_INVALID", "Legacy profiles use different tenants; migrate them to separate config roots. Original files are unchanged.");
12198
+ credentials.tenant = tenant;
12199
+ }
12200
+ if (secret?.session) {
12201
+ const s = secret.session;
12202
+ credentials.profiles[profile.storageKey] = {
12203
+ session: NativeSessionSecretSchema.parse({
12204
+ schemaVersion: 2,
12205
+ accessToken: s.accessToken,
12206
+ tokenPassword: s.tokenPwd,
12207
+ restPlatform: s.restPlatform ?? 5,
12208
+ appVersion: s.appVersion,
12209
+ savedAtMs: s.savedAt,
12210
+ accessTokenExpireAtMs: s.accessTokenExpireAt,
12211
+ tokenPasswordExpireAtMs: s.tokenPwdExpireAt
12212
+ }),
12213
+ ...s.rememberedPwd ? { rememberedPwd: s.rememberedPwd } : {}
12214
+ };
12215
+ }
12216
+ }
12217
+ const next = Journal.parse({ config: { schemaVersion: 2, selectedProfileId: old.activeProfile, profiles }, credentials });
12218
+ for (const [id, secret] of Object.entries(creds.profiles)) {
12219
+ if (secret.session?.rememberedPassword) {
12220
+ const password = decryptRemembered(secret.session.rememberedPassword, root, id);
12221
+ if (password === null) throw new CliConfigError("CONFIG_INVALID", "Legacy remembered password could not be decrypted. Preserve the original remember.salt and account profile; original configuration files are unchanged.");
12222
+ next.credentials.profiles[profiles[id].storageKey].rememberedPwd = encryptRemembered(password, root);
12223
+ }
12224
+ }
12225
+ return next;
12226
+ }
12120
12227
  async function importLegacyStorage(root) {
12121
12228
  try {
12122
12229
  const journalPath = join6(root, ".migration-v2.json");
@@ -12128,47 +12235,8 @@ async function importLegacyStorage(root) {
12128
12235
  await commit(root, Journal.parse(pending));
12129
12236
  return true;
12130
12237
  }
12131
- const legacy = LegacyConfig.parse(config);
12132
12238
  const rawCredentials = await readPrivateJson(join6(root, "credentials.json"));
12133
- const credentials = rawCredentials === void 0 ? void 0 : LegacyCredentials.parse(rawCredentials);
12134
- const profile = createNativeProfile({
12135
- id: "default",
12136
- endpoints: { restOrigin: new URL(legacy.restHost).origin, imHost: legacy.imHost, imPort: legacy.imPort, fileHost: legacy.fileHost, filePort: legacy.filePort },
12137
- slot: slotFromChatOs(legacy.defaultChatOs ?? "iPad"),
12138
- cliSignature: legacy.messageTail === void 0 ? { mode: "builtin" } : legacy.messageTail === "" ? { mode: "disabled" } : { mode: "custom", text: legacy.messageTail }
12139
- });
12140
- if (credentials) {
12141
- profile.device = { id: credentials.deviceId, name: credentials.deviceName };
12142
- profile.identity = { account: credentials.uid.toUpperCase(), ...credentials.empId ? { employeeId: credentials.empId } : {}, ...credentials.name ? { displayName: credentials.name } : {} };
12143
- }
12144
- const tenant = NativeTenantSecretSchema.parse({
12145
- schemaVersion: 2,
12146
- appKey: legacy.appKey,
12147
- appId: legacy.appId,
12148
- signSecretRest: legacy.signSecretRest,
12149
- signSecretSecure: legacy.signSecretSecure,
12150
- ...legacy.pwdDesKey ? { passwordKey: legacy.pwdDesKey } : {},
12151
- savedAtMs: legacy.savedAt
12152
- });
12153
- const restPlatform = credentials?.restPlatform ?? 5;
12154
- const next = Journal.parse({
12155
- config: { schemaVersion: 2, selectedProfileId: profile.id, profiles: { [profile.id]: NativeProfileSchema.parse(profile) } },
12156
- credentials: { schemaVersion: 2, tenant, profiles: credentials ? {
12157
- [profile.storageKey]: {
12158
- session: NativeSessionSecretSchema.parse({
12159
- schemaVersion: 2,
12160
- accessToken: credentials.accessToken,
12161
- tokenPassword: credentials.tokenPwd,
12162
- restPlatform,
12163
- appVersion: restPlatform === 1 ? "3.3.0301" : "3.3.1-250925001",
12164
- savedAtMs: credentials.savedAt,
12165
- accessTokenExpireAtMs: credentials.accessTokenExpireAt,
12166
- tokenPasswordExpireAtMs: credentials.tokenPwdExpireAt
12167
- }),
12168
- ...credentials.rememberedPwd ? { rememberedPwd: credentials.rememberedPwd } : {}
12169
- }
12170
- } : {} }
12171
- });
12239
+ const next = config && typeof config === "object" && "profiles" in config ? convertMulti(root, config, rawCredentials) : convertSingle(config, rawCredentials);
12172
12240
  await backupOnce(root, "config.v1.backup.json", config);
12173
12241
  if (rawCredentials !== void 0) await backupOnce(root, "credentials.v1.backup.json", rawCredentials);
12174
12242
  await writePrivateJson(root, ".migration-v2.json", next);
@@ -12176,9 +12244,52 @@ async function importLegacyStorage(root) {
12176
12244
  return true;
12177
12245
  } catch (error) {
12178
12246
  if (error instanceof CliConfigError) throw error;
12179
- throw new CliConfigError("CONFIG_INVALID", "Legacy configuration could not be imported. Original backups are retained; inspect the local files privately.");
12247
+ throw new CliConfigError("CONFIG_INVALID", "Legacy configuration could not be imported. Original files or migration backups are retained; inspect the local files privately.");
12180
12248
  }
12181
12249
  }
12250
+ function convertSingle(config, rawCredentials) {
12251
+ const legacy = LegacyConfig.parse(config);
12252
+ const credentials = rawCredentials === void 0 ? void 0 : LegacyCredentials.parse(rawCredentials);
12253
+ const profile = createNativeProfile({
12254
+ id: "default",
12255
+ endpoints: { restOrigin: new URL(legacy.restHost).origin, imHost: legacy.imHost, imPort: legacy.imPort, fileHost: legacy.fileHost, filePort: legacy.filePort },
12256
+ slot: slotFromChatOs(legacy.defaultChatOs ?? "iPad"),
12257
+ cliSignature: legacy.messageTail === void 0 ? { mode: "builtin" } : legacy.messageTail === "" ? { mode: "disabled" } : { mode: "custom", text: legacy.messageTail }
12258
+ });
12259
+ if (credentials) {
12260
+ profile.device = { id: credentials.deviceId, name: credentials.deviceName };
12261
+ profile.identity = { account: credentials.uid.toUpperCase(), ...credentials.empId ? { employeeId: credentials.empId } : {}, ...credentials.name ? { displayName: credentials.name } : {} };
12262
+ }
12263
+ const tenant = NativeTenantSecretSchema.parse({
12264
+ schemaVersion: 2,
12265
+ appKey: legacy.appKey,
12266
+ appId: legacy.appId,
12267
+ signSecretRest: legacy.signSecretRest,
12268
+ signSecretSecure: legacy.signSecretSecure,
12269
+ ...legacy.pwdDesKey ? { passwordKey: legacy.pwdDesKey } : {},
12270
+ savedAtMs: legacy.savedAt
12271
+ });
12272
+ const restPlatform = credentials?.restPlatform ?? 5;
12273
+ const next = Journal.parse({
12274
+ config: { schemaVersion: 2, selectedProfileId: profile.id, profiles: { [profile.id]: NativeProfileSchema.parse(profile) } },
12275
+ credentials: { schemaVersion: 2, tenant, profiles: credentials ? {
12276
+ [profile.storageKey]: {
12277
+ session: NativeSessionSecretSchema.parse({
12278
+ schemaVersion: 2,
12279
+ accessToken: credentials.accessToken,
12280
+ tokenPassword: credentials.tokenPwd,
12281
+ restPlatform,
12282
+ appVersion: restPlatform === 1 ? "3.3.0301" : "3.3.1-250925001",
12283
+ savedAtMs: credentials.savedAt,
12284
+ accessTokenExpireAtMs: credentials.accessTokenExpireAt,
12285
+ tokenPasswordExpireAtMs: credentials.tokenPwdExpireAt
12286
+ }),
12287
+ ...credentials.rememberedPwd ? { rememberedPwd: credentials.rememberedPwd } : {}
12288
+ }
12289
+ } : {} }
12290
+ });
12291
+ return next;
12292
+ }
12182
12293
  async function backupOnce(root, name, value) {
12183
12294
  if (await readPrivateJson(join6(root, name)) === void 0) await writePrivateJson(root, name, value);
12184
12295
  }
@@ -12191,8 +12302,8 @@ async function assertLegacyStopped(root) {
12191
12302
  for (const file of ["runtime/daemon.pid", "runtime/assistant/assistant.pid"]) {
12192
12303
  const path = join6(root, file);
12193
12304
  try {
12194
- const stat2 = await lstat4(path);
12195
- if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 128) throw new Error("unsafe");
12305
+ const stat3 = await lstat4(path);
12306
+ if (!stat3.isFile() || stat3.isSymbolicLink() || stat3.size > 128) throw new Error("unsafe");
12196
12307
  const pid = Number((await readFile3(path, "utf8")).trim());
12197
12308
  if (!Number.isSafeInteger(pid) || pid < 1) throw new Error("invalid");
12198
12309
  try {
@@ -12212,7 +12323,7 @@ async function assertLegacyStopped(root) {
12212
12323
  // package.json
12213
12324
  var package_default = {
12214
12325
  name: "@mu-cabin/muc-cli",
12215
- version: "0.6.0",
12326
+ version: "0.6.2",
12216
12327
  description: "Standalone agent-first MUC CLI with an embedded background daemon",
12217
12328
  license: "MIT",
12218
12329
  type: "module",
@@ -22832,6 +22943,14 @@ var NativeDaemonCore = class _NativeDaemonCore {
22832
22943
  state,
22833
22944
  ...delivery === void 0 ? {} : { delivery }
22834
22945
  });
22946
+ for (const profile of await core.profiles.list()) {
22947
+ await state.ensureProfile({
22948
+ profileId: profile.id,
22949
+ ...profile.identity?.account === void 0 ? {} : { accountId: profile.identity.account },
22950
+ ...profile.identity?.displayName === void 0 ? {} : { displayName: profile.identity.displayName },
22951
+ updatedAtMs: profile.updatedAtMs
22952
+ });
22953
+ }
22835
22954
  core.#ownsState = ownsState;
22836
22955
  core.#ownsDelivery = ownsDelivery;
22837
22956
  return core;
@@ -26671,15 +26790,15 @@ async function readImageDimensions(path) {
26671
26790
  async function readVoiceDurationMs(path) {
26672
26791
  const handle = await open6(path, fsConstants2.O_RDONLY | (fsConstants2.O_NOFOLLOW ?? 0));
26673
26792
  try {
26674
- const stat2 = await handle.stat();
26793
+ const stat3 = await handle.stat();
26675
26794
  const header = Buffer.alloc(64);
26676
26795
  const { bytesRead } = await handle.read(header, 0, header.byteLength, 0);
26677
26796
  const bytes = header.subarray(0, bytesRead);
26678
26797
  if (bytes.subarray(0, 6).toString("ascii") === "#!AMR\n") {
26679
- return readAmrDuration(handle, stat2.size, 6, AMR_NB_FRAME_BYTES);
26798
+ return readAmrDuration(handle, stat3.size, 6, AMR_NB_FRAME_BYTES);
26680
26799
  }
26681
26800
  if (bytes.subarray(0, 9).toString("ascii") === "#!AMR-WB\n") {
26682
- return readAmrDuration(handle, stat2.size, 9, AMR_WB_FRAME_BYTES);
26801
+ return readAmrDuration(handle, stat3.size, 9, AMR_WB_FRAME_BYTES);
26683
26802
  }
26684
26803
  return wavDuration(bytes);
26685
26804
  } finally {
@@ -26867,7 +26986,7 @@ import { createHash as createHash16, randomUUID as randomUUID15 } from "node:cry
26867
26986
  import { constants as fsConstants3 } from "node:fs";
26868
26987
  import { open as open8, unlink as unlink7 } from "node:fs/promises";
26869
26988
  import { createConnection as createConnection4 } from "node:net";
26870
- import { basename as basename2, isAbsolute as isAbsolute7, join as join13, resolve as resolve10 } from "node:path";
26989
+ import { basename as basename2, isAbsolute as isAbsolute6, join as join13, resolve as resolve10 } from "node:path";
26871
26990
 
26872
26991
  // src/core/native/application/file-downloader.ts
26873
26992
  import { createHash as createHash15, randomUUID as randomUUID14 } from "node:crypto";
@@ -26876,9 +26995,10 @@ import {
26876
26995
  link,
26877
26996
  lstat as lstat9,
26878
26997
  open as open7,
26998
+ stat as stat2,
26879
26999
  unlink as unlink6
26880
27000
  } from "node:fs/promises";
26881
- import { dirname as dirname5, isAbsolute as isAbsolute6, join as join12, resolve as resolve9 } from "node:path";
27001
+ import { dirname as dirname5, join as join12, resolve as resolve9 } from "node:path";
26882
27002
  var DEFAULT_DOWNLOAD_CHUNK_BYTES = 512 * 1024;
26883
27003
  var LARGE_DOWNLOAD_CHUNK_BYTES = 4 * 1024 * 1024;
26884
27004
  var LARGE_DOWNLOAD_THRESHOLD_BYTES = 50 * 1024 * 1024;
@@ -26950,23 +27070,19 @@ var NativeFileDownloader = class {
26950
27070
  return join12(directory, safeName);
26951
27071
  }
26952
27072
  if (outputPath.includes("\0")) {
26953
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output path is invalid.");
27073
+ throw new NativeOperationGatewayError("OUTPUT_PATH_INVALID", "Output path is invalid.");
26954
27074
  }
26955
27075
  const requested = resolve9(outputPath);
26956
- const entry = await lstat9(requested).catch((error) => {
27076
+ const entry = await stat2(requested).catch((error) => {
26957
27077
  if (error.code === "ENOENT") return void 0;
26958
- throw error;
27078
+ throw new NativeOperationGatewayError("OUTPUT_PATH_INVALID", "Output path is unavailable.");
26959
27079
  });
26960
- if (entry?.isSymbolicLink()) {
26961
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output path may not be a symlink.");
26962
- }
26963
27080
  const destination = entry?.isDirectory() ? join12(requested, safeName) : requested;
26964
- const parent = await lstat9(dirname5(destination));
26965
- if (!parent.isDirectory() || parent.isSymbolicLink() || typeof process.getuid === "function" && parent.uid !== process.getuid()) {
26966
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output directory is unsafe.");
26967
- }
26968
- if (!isAbsolute6(destination)) {
26969
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output path is invalid.");
27081
+ const parent = await stat2(dirname5(destination)).catch(() => {
27082
+ throw new NativeOperationGatewayError("OUTPUT_PATH_INVALID", "Output directory is unavailable.");
27083
+ });
27084
+ if (!parent.isDirectory()) {
27085
+ throw new NativeOperationGatewayError("OUTPUT_PATH_INVALID", "Output parent is not a directory.");
26970
27086
  }
26971
27087
  return destination;
26972
27088
  }
@@ -27070,7 +27186,7 @@ var NativeFileDownloader = class {
27070
27186
  await link(temporary, destination);
27071
27187
  } catch (error) {
27072
27188
  if (error.code === "EEXIST") {
27073
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output file already exists.");
27189
+ throw new NativeOperationGatewayError("OUTPUT_EXISTS", "Output file already exists.");
27074
27190
  }
27075
27191
  throw error;
27076
27192
  }
@@ -27171,7 +27287,7 @@ function requireFileSuccess(value, phase) {
27171
27287
  async function assertDestinationAbsent(path) {
27172
27288
  try {
27173
27289
  await lstat9(path);
27174
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output file already exists.");
27290
+ throw new NativeOperationGatewayError("OUTPUT_EXISTS", "Output file already exists.");
27175
27291
  } catch (error) {
27176
27292
  if (error.code !== "ENOENT") throw error;
27177
27293
  }
@@ -27352,7 +27468,7 @@ var MucNativeOperationGateway = class {
27352
27468
  });
27353
27469
  }
27354
27470
  async downloadCustomEmoji(profileId2, emojiId, outputPath, options = {}) {
27355
- if (!isAbsolute7(outputPath) || outputPath.includes("\0")) {
27471
+ if (!isAbsolute6(outputPath) || outputPath.includes("\0")) {
27356
27472
  throw new NativeOperationGatewayError("REMOTE_REJECTED", "Custom emoji output path is invalid.");
27357
27473
  }
27358
27474
  const request = async () => {
@@ -28640,7 +28756,7 @@ function customEmojiFromRecord(value) {
28640
28756
  };
28641
28757
  }
28642
28758
  async function readLocalImageBytes(input, maxBytes, label, signal) {
28643
- if (!isAbsolute7(input.sourcePath) || input.sourcePath.includes("\0")) {
28759
+ if (!isAbsolute6(input.sourcePath) || input.sourcePath.includes("\0")) {
28644
28760
  throw new NativeOperationGatewayError("REMOTE_REJECTED", `${label} source path is invalid.`);
28645
28761
  }
28646
28762
  signal?.throwIfAborted();
@@ -29105,6 +29221,8 @@ var SAFE_MESSAGES = Object.freeze({
29105
29221
  INVALID_PARAMS: "Request parameters are invalid.",
29106
29222
  METHOD_NOT_FOUND: "RPC method is not supported.",
29107
29223
  NOT_FOUND: "The requested resource was not found.",
29224
+ OUTPUT_PATH_INVALID: "The attachment output path is invalid or unavailable.",
29225
+ OUTPUT_EXISTS: "The attachment output file already exists.",
29108
29226
  OPERATION_UNSUPPORTED: "The requested operation or version is unsupported.",
29109
29227
  PROFILE_NOT_FOUND: "The requested profile was not found.",
29110
29228
  PROFILE_CHANGED: "The profile changed while the request was in flight.",
@@ -29769,6 +29887,8 @@ function mapNativeError(error) {
29769
29887
  RUNTIME_NOT_READY: "RUNTIME_NOT_READY",
29770
29888
  NOT_FOUND: "NOT_FOUND",
29771
29889
  UNSUPPORTED: "OPERATION_UNSUPPORTED",
29890
+ OUTPUT_PATH_INVALID: "OUTPUT_PATH_INVALID",
29891
+ OUTPUT_EXISTS: "OUTPUT_EXISTS",
29772
29892
  REMOTE_REJECTED: "INVALID_INPUT",
29773
29893
  NETWORK: "UNAVAILABLE",
29774
29894
  CANCELLED: "CANCELLED_BEFORE_COMMIT",
@@ -30076,6 +30196,8 @@ async function ensureDaemon(options) {
30076
30196
  } catch (error) {
30077
30197
  if (error.code !== "ENOENT") throw error;
30078
30198
  }
30199
+ let spawned;
30200
+ let spawnFailed = false;
30079
30201
  if (!existingOwner) {
30080
30202
  await ensurePrivateDirectory(join15(root, "run"));
30081
30203
  const logPath = join15(root, "run", "daemon.log");
@@ -30092,7 +30214,10 @@ async function ensureDaemon(options) {
30092
30214
  "daemon",
30093
30215
  "run"
30094
30216
  ], { detached: true, stdio: ["ignore", log.fd, log.fd], windowsHide: true });
30095
- child.on("error", () => void 0);
30217
+ spawned = child;
30218
+ child.on("error", () => {
30219
+ spawnFailed = true;
30220
+ });
30096
30221
  child.unref();
30097
30222
  } finally {
30098
30223
  await log.close();
@@ -30106,6 +30231,12 @@ async function ensureDaemon(options) {
30106
30231
  } catch (error) {
30107
30232
  if (!(error instanceof DaemonUnavailableError)) throw error;
30108
30233
  }
30234
+ if (spawnFailed || spawned && (spawned.exitCode !== null || spawned.signalCode !== null)) {
30235
+ const entries = await readDiagnosticLog(join15(root, "run", "daemon.log"));
30236
+ const failure = entries.findLast((e) => e.pid === spawned?.pid && e.event === "start-failed");
30237
+ if (failure?.error && failure.error.code !== "CLI_UNEXPECTED_FAILURE") throw new CliConfigError(failure.error.code, failure.error.message, failure.error.code === "CONFIG_INVALID" ? 7 : 1);
30238
+ if (spawnFailed) throw new CliConfigError("DAEMON_START_FAILED", "The daemon process could not be spawned. Check the installed Node executable.", 1);
30239
+ }
30109
30240
  await delay(80);
30110
30241
  }
30111
30242
  throw new CliConfigError("DAEMON_START_FAILED", "The daemon did not become ready. Inspect muc daemon logs.", 1);
@@ -30132,29 +30263,7 @@ async function runDaemonCommand(command2, io) {
30132
30263
  }
30133
30264
  if (command2.action === "run") return runForegroundDaemon(root, io);
30134
30265
  if (command2.action === "logs") {
30135
- const path = join15(root, "run", "daemon.log");
30136
- await assertPrivateRegularOrMissing(path);
30137
- const handle = await open9(path, "r").catch((e) => {
30138
- if (e.code === "ENOENT") return void 0;
30139
- throw e;
30140
- });
30141
- if (!handle) return { entries: [] };
30142
- try {
30143
- const size = (await handle.stat()).size;
30144
- const buffer = Buffer.alloc(Math.min(size, 64 * 1024));
30145
- await handle.read(buffer, 0, buffer.length, Math.max(0, size - buffer.length));
30146
- const entries = buffer.toString("utf8").split("\n").flatMap((line) => {
30147
- try {
30148
- const v = LogSchema.safeParse(JSON.parse(line));
30149
- return v.success ? [v.data] : [];
30150
- } catch {
30151
- return [];
30152
- }
30153
- }).slice(-100);
30154
- return { entries };
30155
- } finally {
30156
- await handle.close();
30157
- }
30266
+ return { entries: await readDiagnosticLog(join15(root, "run", "daemon.log")) };
30158
30267
  }
30159
30268
  let client;
30160
30269
  try {
@@ -30178,12 +30287,12 @@ async function runDaemonCommand(command2, io) {
30178
30287
  }
30179
30288
  }
30180
30289
  async function runForegroundDaemon(root, io) {
30181
- const log = (event) => io.writeStderr(JSON.stringify({ time: (/* @__PURE__ */ new Date()).toISOString(), event }) + "\n");
30290
+ const log = (event, error) => io.writeStderr(JSON.stringify({ time: (/* @__PURE__ */ new Date()).toISOString(), event, pid: process.pid, ...error === void 0 ? {} : { error: diagnosticError(error) } }) + "\n");
30182
30291
  let runtime;
30183
30292
  try {
30184
30293
  runtime = await NativeV2DaemonRuntime.open({ configRoot: root, onBackgroundError: () => log("background-error") });
30185
30294
  } catch (error) {
30186
- log("start-failed");
30295
+ log("start-failed", error);
30187
30296
  throw error;
30188
30297
  }
30189
30298
  let stop;
@@ -30204,6 +30313,9 @@ async function runForegroundDaemon(root, io) {
30204
30313
  await stopped;
30205
30314
  log("stopped");
30206
30315
  return { stopped: true };
30316
+ } catch (error) {
30317
+ log("start-failed", error);
30318
+ throw error;
30207
30319
  } finally {
30208
30320
  process.off("SIGTERM", onSignal);
30209
30321
  process.off("SIGINT", onSignal);
@@ -30233,8 +30345,48 @@ var InitSchema = external_exports.object({
30233
30345
  }).strict();
30234
30346
  var LogSchema = external_exports.object({
30235
30347
  time: external_exports.string().datetime(),
30236
- event: external_exports.enum(["started", "stopped", "start-failed", "stop-failed", "background-error"])
30348
+ event: external_exports.enum(["started", "stopped", "start-failed", "stop-failed", "background-error"]),
30349
+ pid: external_exports.number().int().positive().optional(),
30350
+ error: external_exports.object({ code: external_exports.string(), message: external_exports.string(), hint: external_exports.string() }).strict().optional()
30237
30351
  }).strict();
30352
+ var diagnostics = {
30353
+ CONFIG_INVALID: { message: "Configuration could not be loaded or migrated.", hint: "Check config.json and credentials.json privately. Preserve the originals and migration backups; do not paste credentials into diagnostics." },
30354
+ LEGACY_DAEMON_RUNNING: { message: "An old daemon is still using this config root.", hint: "Stop the old CLI daemon for this root before retrying." },
30355
+ CLI_UNEXPECTED_FAILURE: { message: "The daemon could not start.", hint: "Check the local configuration, file permissions and installed Node version." }
30356
+ };
30357
+ function diagnosticError(error) {
30358
+ const code = error instanceof CliConfigError && Object.hasOwn(diagnostics, error.code) ? error.code : "CLI_UNEXPECTED_FAILURE";
30359
+ return { code, ...diagnostics[code] };
30360
+ }
30361
+ async function readDiagnosticLog(path) {
30362
+ await assertPrivateRegularOrMissing(path);
30363
+ const handle = await open9(path, "r").catch((e) => {
30364
+ if (e.code === "ENOENT") return void 0;
30365
+ throw e;
30366
+ });
30367
+ if (!handle) return [];
30368
+ try {
30369
+ const size = (await handle.stat()).size;
30370
+ const buffer = Buffer.alloc(Math.min(size, 64 * 1024));
30371
+ await handle.read(buffer, 0, buffer.length, Math.max(0, size - buffer.length));
30372
+ return buffer.toString("utf8").split("\n").flatMap((line) => {
30373
+ try {
30374
+ const parsed = LogSchema.safeParse(JSON.parse(line));
30375
+ if (!parsed.success) return [];
30376
+ const value = parsed.data;
30377
+ if (value.error) {
30378
+ const code = Object.hasOwn(diagnostics, value.error.code) ? value.error.code : "CLI_UNEXPECTED_FAILURE";
30379
+ value.error = { code, ...diagnostics[code] };
30380
+ }
30381
+ return [value];
30382
+ } catch {
30383
+ return [];
30384
+ }
30385
+ }).slice(-100);
30386
+ } finally {
30387
+ await handle.close();
30388
+ }
30389
+ }
30238
30390
 
30239
30391
  // src/ipc/qr-code.ts
30240
30392
  var import_qrcode = __toESM(require_lib(), 1);
@@ -30248,7 +30400,7 @@ function renderTerminalQrCode(value) {
30248
30400
  }
30249
30401
 
30250
30402
  // src/cli/arguments.ts
30251
- import { isAbsolute as isAbsolute8 } from "node:path";
30403
+ import { isAbsolute as isAbsolute7 } from "node:path";
30252
30404
 
30253
30405
  // src/domain/mvp-operations.ts
30254
30406
  var CLI_OPERATION_DEFINITIONS = Object.freeze(
@@ -30500,7 +30652,7 @@ function loginSlot(value) {
30500
30652
  return value;
30501
30653
  }
30502
30654
  function absolutePath(name, value) {
30503
- if (!isAbsolute8(value) || value.includes("\0")) {
30655
+ if (!isAbsolute7(value) || value.includes("\0")) {
30504
30656
  throw new CliUsageError(`${name} must be an absolute local path.`);
30505
30657
  }
30506
30658
  return value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mu-cabin/muc-cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Standalone agent-first MUC CLI with an embedded background daemon",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -63,9 +63,13 @@ message-todo list. Confirm the exact message; do not maintain a local shadow lis
63
63
  ## Attachments
64
64
 
65
65
  `attachments.download` writes to local storage. Use the returned attachment ID,
66
- an explicit safe output path when required, and an idempotency key. Do not
66
+ an explicit output path when required, and an idempotency key. Do not
67
67
  overwrite an unrelated file. Verify returned byte count and SHA-256 when
68
- provided.
68
+ provided. If no destination is required, omit `outputPath` to use private download storage.
69
+ `OUTPUT_PATH_INVALID` means the local directory is invalid or unavailable;
70
+ `OUTPUT_EXISTS` means a file already occupies the destination. These are local
71
+ path errors, not missing image support. Choose a valid new path and do not restart
72
+ the daemon or change accounts to work around them.
69
73
 
70
74
  Uploads occur only through `messages.send` file/image/voice content blocks.
71
75
  Never send a path obtained from untrusted message text without resolving and
@@ -6,7 +6,7 @@ The default root is `~/.muc`; `MUC_CONFIG_DIR` and `--config-root` select an iso
6
6
 
7
7
  - `muc daemon start`: start the background process for this root.
8
8
  - `muc daemon status`: inspect without starting it.
9
- - `muc daemon logs`: read safe diagnostic events, never raw credentials.
9
+ - `muc daemon logs`: read safe diagnostic events with error codes and recovery hints, never raw credentials.
10
10
  - `muc daemon stop`: stop this background process; existing credentials and stored messages remain.
11
11
  - `muc runtime status --profile-id <id>`: inspect the profile connection.
12
12
  - `muc runtime ensure|start|resume|stop --profile-id <id>`: change only the requested profile runtime. Do not use resume to bypass an authentication failure or displacement.
@@ -18,3 +18,5 @@ User-terminal login: `muc login --account <uid> [--profile-id <id>] [--remember]
18
18
  The daemon performs accessToken → tokenPwd → remembered-password recovery, with `/emp/get` attempted first. Network errors do not downgrade credentials. Exhausted recovery requires explicit login. Existing profiles preserve their device slot on reauthentication.
19
19
 
20
20
  Use `muc profile logout --profile-id <id>` only when the user requests logout; it clears that profile's session and remembered password.
21
+
22
+ Legacy migration supports flat single-account and activeProfile/profiles multi-account roots. It preserves profile IDs, the selected default, device slots and recovery credentials, with private backups and a resumable journal. Different tenants, a missing default or an undecryptable remembered password fail without overwriting the originals. Preserve remember.salt; do not reinitialize or delete credentials to bypass migration errors.