@dsh-enhanced/lark-channel 0.1.23 → 0.1.30

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.
Files changed (43) hide show
  1. package/README.md +14 -3
  2. package/docs/operations.md +8 -0
  3. package/docs/setup.md +3 -0
  4. package/lib/adapter.d.ts +7 -0
  5. package/lib/adapter.d.ts.map +1 -1
  6. package/lib/adapter.js +18 -4
  7. package/lib/adapter.js.map +1 -1
  8. package/lib/config.d.ts +3 -0
  9. package/lib/config.d.ts.map +1 -1
  10. package/lib/config.js +8 -0
  11. package/lib/config.js.map +1 -1
  12. package/lib/index.d.ts +2 -1
  13. package/lib/index.d.ts.map +1 -1
  14. package/lib/index.js +1 -1
  15. package/lib/index.js.map +1 -1
  16. package/lib/sdk.d.ts +17 -2
  17. package/lib/sdk.d.ts.map +1 -1
  18. package/lib/sdk.js +170 -76
  19. package/lib/sdk.js.map +1 -1
  20. package/lib/service.d.ts +17 -0
  21. package/lib/service.d.ts.map +1 -1
  22. package/lib/service.js +68 -5
  23. package/lib/service.js.map +1 -1
  24. package/lib/setup-profile.d.ts.map +1 -1
  25. package/lib/setup-profile.js +1 -0
  26. package/lib/setup-profile.js.map +1 -1
  27. package/lib/setup.d.ts +4 -0
  28. package/lib/setup.d.ts.map +1 -1
  29. package/lib/setup.js +313 -242
  30. package/lib/setup.js.map +1 -1
  31. package/lib/supervised-growth-profile.d.ts +27 -0
  32. package/lib/supervised-growth-profile.d.ts.map +1 -1
  33. package/lib/supervised-growth-profile.js +112 -0
  34. package/lib/supervised-growth-profile.js.map +1 -1
  35. package/lib/supervised-growth-setup.d.ts +82 -1
  36. package/lib/supervised-growth-setup.d.ts.map +1 -1
  37. package/lib/supervised-growth-setup.js +430 -85
  38. package/lib/supervised-growth-setup.js.map +1 -1
  39. package/lib/types.d.ts +12 -0
  40. package/lib/types.d.ts.map +1 -1
  41. package/lib/version.d.ts +1 -1
  42. package/lib/version.js +1 -1
  43. package/package.json +14 -14
package/lib/setup.js CHANGED
@@ -32,6 +32,7 @@ export function parseLarkSetupArgs(argv) {
32
32
  accountProvided: false,
33
33
  tenant: 'personal',
34
34
  createApp: false,
35
+ calendarReadonly: false,
35
36
  appName: 'DSH Personal Assistant',
36
37
  timeoutMs: 300_000,
37
38
  installServiceOnly: false,
@@ -63,6 +64,8 @@ export function parseLarkSetupArgs(argv) {
63
64
  result.appId = argumentValue(argv, index++, option);
64
65
  else if (option === '--create-app')
65
66
  result.createApp = true;
67
+ else if (option === '--calendar-readonly')
68
+ result.calendarReadonly = true;
66
69
  else if (option === '--install-service')
67
70
  result.installServiceOnly = true;
68
71
  else if (option === '--refresh-agent-policy')
@@ -109,6 +112,9 @@ export function parseLarkSetupArgs(argv) {
109
112
  if (result.installServiceOnly && !result.manageService) {
110
113
  throw new Error('lark-channel setup: --install-service and --no-service cannot be used together');
111
114
  }
115
+ if (result.calendarReadonly && !result.createApp) {
116
+ throw new Error('lark-channel setup: --calendar-readonly requires --create-app so the application scope can be updated');
117
+ }
112
118
  if (result.installServiceOnly && (result.createApp || result.appId !== undefined)) {
113
119
  throw new Error('lark-channel setup: --install-service cannot be combined with application setup options');
114
120
  }
@@ -153,6 +159,7 @@ export function createLarkRegistrationOptions(input) {
153
159
  'im:message.reactions:write_only',
154
160
  'im:message:send_as_bot',
155
161
  'im:resource',
162
+ ...(input.calendarReadonly ? ['calendar:calendar:readonly'] : []),
156
163
  ] },
157
164
  events: { items: { tenant: ['im.message.receive_v1'] } },
158
165
  callbacks: { items: ['card.action.trigger'] },
@@ -276,6 +283,7 @@ Options:
276
283
  --account <id> Local channel account id (default: primary)
277
284
  --tenant <id> Local tenant namespace (default: personal)
278
285
  --create-app Select, create, or officially update an app
286
+ --calendar-readonly With --create-app, add Calendar read-only permission for exact calendar triggers
279
287
  --app-name <name> New app name (default: DSH Personal Assistant)
280
288
  --app-id <cli_...> Existing app id; combine with --create-app to update it
281
289
  --install-service Only install/restart the profile's resident service
@@ -1137,6 +1145,7 @@ async function registerLarkApplication(args) {
1137
1145
  domain: args.domain,
1138
1146
  appName: args.appName,
1139
1147
  ...(args.appId === undefined ? {} : { appId: args.appId }),
1148
+ ...(args.calendarReadonly ? { calendarReadonly: true } : {}),
1140
1149
  signal: abortController.signal,
1141
1150
  onQRCodeReady(info) {
1142
1151
  process.stdout.write(`\n请在飞书中打开以下链接,选择已有应用或创建新应用(${info.expireIn} 秒内有效):\n${info.url}\n\n`);
@@ -1197,6 +1206,65 @@ async function discoverOwner(transport, phrase, account, tenant, timeoutMs) {
1197
1206
  await transport.disconnect().catch(() => { });
1198
1207
  }
1199
1208
  }
1209
+ export function dshHomeLifecycleRendezvousPath(dshHome, uid = process.getuid?.()) {
1210
+ if (!isAbsolute(dshHome))
1211
+ throw new Error('lark-channel setup: DSH_HOME must be absolute');
1212
+ if (!Number.isSafeInteger(uid) || uid === undefined || uid < 0) {
1213
+ throw new Error('lark-channel setup: cannot determine the Linux user id for lifecycle locking');
1214
+ }
1215
+ const canonicalHome = normalizeSetupResourcePath(dshHome);
1216
+ return `/tmp/.dsh-enhanced-lifecycle-${uid}-${textSha256(canonicalHome)}.lock`;
1217
+ }
1218
+ async function assertDshHomeLifecycleLockIdentity(descriptor, lockPath, uid) {
1219
+ const [opened, linked] = await Promise.all([descriptor.stat(), lstat(lockPath)]);
1220
+ if (!opened.isFile() || !linked.isFile() || linked.isSymbolicLink()
1221
+ || opened.uid !== uid || linked.uid !== uid
1222
+ || opened.nlink !== 1 || linked.nlink !== 1
1223
+ || (opened.mode & 0o777) !== 0o600 || (linked.mode & 0o777) !== 0o600
1224
+ || opened.dev !== linked.dev || opened.ino !== linked.ino) {
1225
+ throw new Error(`lark-channel setup: lifecycle rendezvous lock is unsafe: ${lockPath}`);
1226
+ }
1227
+ }
1228
+ export async function withDshHomeLifecycleLock(dshHome, operation) {
1229
+ if (process.platform !== 'linux')
1230
+ return operation();
1231
+ const uid = process.getuid?.();
1232
+ if (!Number.isSafeInteger(uid) || uid === undefined || uid < 0) {
1233
+ throw new Error('lark-channel setup: cannot determine the Linux user id for lifecycle locking');
1234
+ }
1235
+ const temporaryDirectory = await lstat('/tmp');
1236
+ if (!temporaryDirectory.isDirectory() || temporaryDirectory.isSymbolicLink()
1237
+ || temporaryDirectory.uid !== 0 || (temporaryDirectory.mode & 0o7777) !== 0o1777) {
1238
+ throw new Error('lark-channel setup: lifecycle rendezvous lock requires a safe root-owned sticky /tmp');
1239
+ }
1240
+ const lockPath = dshHomeLifecycleRendezvousPath(dshHome, uid);
1241
+ let descriptor;
1242
+ try {
1243
+ descriptor = await open(lockPath, constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
1244
+ await assertDshHomeLifecycleLockIdentity(descriptor, lockPath, uid);
1245
+ const acquired = spawnSync('/usr/bin/flock', ['-n', '-E', '75', '3'], {
1246
+ encoding: 'utf8',
1247
+ stdio: ['ignore', 'ignore', 'pipe', descriptor.fd],
1248
+ });
1249
+ if (acquired.status === 75)
1250
+ throw new Error(`lark-channel setup: DSH_HOME lifecycle is busy: ${lockPath}`);
1251
+ if (acquired.status !== 0) {
1252
+ const detail = typeof acquired.stderr === 'string' ? acquired.stderr.trim().slice(0, 1_000) : '';
1253
+ throw new Error(`lark-channel setup: cannot acquire DSH_HOME lifecycle lock${detail === '' ? '' : `: ${detail}`}`);
1254
+ }
1255
+ await assertDshHomeLifecycleLockIdentity(descriptor, lockPath, uid);
1256
+ return await operation();
1257
+ }
1258
+ catch (error) {
1259
+ if (fileSystemErrorCode(error) === 'ELOOP') {
1260
+ throw new Error(`lark-channel setup: lifecycle rendezvous lock is unsafe: ${lockPath}`);
1261
+ }
1262
+ throw error;
1263
+ }
1264
+ finally {
1265
+ await descriptor?.close();
1266
+ }
1267
+ }
1200
1268
  const defaultProfileSetupLockOptions = {
1201
1269
  timeoutMs: 30_000,
1202
1270
  pollMs: 100,
@@ -2733,285 +2801,288 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
2733
2801
  const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
2734
2802
  if (!isAbsolute(dshHome))
2735
2803
  throw new Error('lark-channel setup: DSH_HOME must be absolute');
2736
- const patchPath = join(dshHome, 'profiles', args.profile, 'cordis.patch.yml');
2737
- const validate = runtime.validateProfile ?? validateProfile;
2738
- const readEffectiveProfile = runtime.readEffectiveProfile ?? dumpProfile;
2739
- const installResidentService = runtime.installResidentService ?? installDshResidentService;
2740
- const prepareResidentService = runtime.prepareResidentService ?? prepareDshSystemdUserService;
2741
- const recoveryOperations = {
2742
- ...runtime.journalOperations,
2743
- readEffectiveProfile: runtime.journalOperations?.readEffectiveProfile ?? readEffectiveProfile,
2744
- installService: runtime.journalOperations?.installService
2745
- ?? (async () => { await installResidentService({ dshHome, profile: args.profile }); }),
2746
- };
2747
- if (args.refreshAgentPolicy) {
2748
- await withProfileSetupLock(patchPath, async () => {
2749
- await recoverLarkSetupJournalUnlocked({
2750
- patchPath,
2751
- dshHome,
2752
- profile: args.profile,
2753
- operations: recoveryOperations,
2754
- });
2755
- const effectiveProfile = await readEffectiveProfile(args.profile);
2756
- const databasePath = deliveryDatabasePathFromEffectiveProfile(effectiveProfile, dshHome);
2757
- await withDeliveryOwnerSetupLock(databasePath, async () => {
2758
- await assertNoOtherLarkProfileSharesDelivery({
2804
+ return withDshHomeLifecycleLock(dshHome, async () => {
2805
+ const patchPath = join(dshHome, 'profiles', args.profile, 'cordis.patch.yml');
2806
+ const validate = runtime.validateProfile ?? validateProfile;
2807
+ const readEffectiveProfile = runtime.readEffectiveProfile ?? dumpProfile;
2808
+ const installResidentService = runtime.installResidentService ?? installDshResidentService;
2809
+ const prepareResidentService = runtime.prepareResidentService ?? prepareDshSystemdUserService;
2810
+ const recoveryOperations = {
2811
+ ...runtime.journalOperations,
2812
+ readEffectiveProfile: runtime.journalOperations?.readEffectiveProfile ?? readEffectiveProfile,
2813
+ installService: runtime.journalOperations?.installService
2814
+ ?? (async () => { await installResidentService({ dshHome, profile: args.profile }); }),
2815
+ };
2816
+ if (args.refreshAgentPolicy) {
2817
+ await withProfileSetupLock(patchPath, async () => {
2818
+ await recoverLarkSetupJournalUnlocked({
2819
+ patchPath,
2759
2820
  dshHome,
2760
2821
  profile: args.profile,
2761
- databasePath,
2762
- readEffectiveProfile,
2763
- });
2764
- const originalPatch = await readFile(patchPath, 'utf8');
2765
- const materializedPatch = materializeManagedProfileOverride({
2766
- profilePatch: originalPatch,
2767
- effectiveProfile,
2822
+ operations: recoveryOperations,
2768
2823
  });
2769
- const refreshInput = {
2770
- profilePatch: materializedPatch,
2771
- effectiveProfile,
2772
- dshHome,
2773
- agentTools: args.agentTools === 'enable' ? 'enable' : 'disable',
2774
- ...(args.accountProvided ? { account: args.account } : {}),
2775
- };
2776
- const updatedPatch = refreshLarkAgentPolicyFromEffective(refreshInput);
2777
- const journal = {
2824
+ const effectiveProfile = await readEffectiveProfile(args.profile);
2825
+ const databasePath = deliveryDatabasePathFromEffectiveProfile(effectiveProfile, dshHome);
2826
+ await withDeliveryOwnerSetupLock(databasePath, async () => {
2827
+ await assertNoOtherLarkProfileSharesDelivery({
2828
+ dshHome,
2829
+ profile: args.profile,
2830
+ databasePath,
2831
+ readEffectiveProfile,
2832
+ });
2833
+ const originalPatch = await readFile(patchPath, 'utf8');
2834
+ const materializedPatch = materializeManagedProfileOverride({
2835
+ profilePatch: originalPatch,
2836
+ effectiveProfile,
2837
+ });
2838
+ const refreshInput = {
2839
+ profilePatch: materializedPatch,
2840
+ effectiveProfile,
2841
+ dshHome,
2842
+ agentTools: args.agentTools === 'enable' ? 'enable' : 'disable',
2843
+ ...(args.accountProvided ? { account: args.account } : {}),
2844
+ };
2845
+ const updatedPatch = refreshLarkAgentPolicyFromEffective(refreshInput);
2846
+ const journal = {
2847
+ patchPath,
2848
+ dshHome,
2849
+ profile: args.profile,
2850
+ operation: 'refresh',
2851
+ phase: 'candidate',
2852
+ originalPatch,
2853
+ updatedPatch,
2854
+ installService: false,
2855
+ transactionId: randomBytes(16).toString('hex'),
2856
+ };
2857
+ await persistLarkSetupJournal(journal);
2858
+ try {
2859
+ await atomicWrite(patchPath, updatedPatch);
2860
+ await validate(args.profile);
2861
+ const validatedEffectiveProfile = await readEffectiveProfile(args.profile);
2862
+ const validatedDatabasePath = deliveryDatabasePathFromEffectiveProfile(validatedEffectiveProfile, dshHome);
2863
+ if (normalizeSetupResourcePath(validatedDatabasePath) !== normalizeSetupResourcePath(databasePath)) {
2864
+ throw new Error('lark-channel setup: effective assistant-delivery database changed during refresh validation');
2865
+ }
2866
+ const validatedSemanticPatch = refreshLarkAgentPolicyFromEffective({
2867
+ ...refreshInput,
2868
+ effectiveProfile: validatedEffectiveProfile,
2869
+ });
2870
+ assertEffectiveManagedPolicyMatchesCandidate({
2871
+ candidatePatch: validatedSemanticPatch,
2872
+ effectiveProfile: validatedEffectiveProfile,
2873
+ });
2874
+ assertEffectiveManagedPolicyMatchesCandidate({
2875
+ candidatePatch: updatedPatch,
2876
+ effectiveProfile: validatedEffectiveProfile,
2877
+ });
2878
+ if (await readFile(patchPath, 'utf8') !== updatedPatch) {
2879
+ throw new Error('lark-channel setup: refreshed profile changed during validation');
2880
+ }
2881
+ }
2882
+ catch (error) {
2883
+ if (await rollbackCandidatePatch({ patchPath, originalPatch, updatedPatch }) === 'conflict') {
2884
+ throw new Error('lark-channel setup: rollback refused because the profile changed concurrently', { cause: error });
2885
+ }
2886
+ await clearLarkSetupJournal(patchPath);
2887
+ throw error;
2888
+ }
2889
+ // If this durable phase transition or journal removal reports an error,
2890
+ // leave recovery to inspect the on-disk phase. Rolling back here could
2891
+ // contradict a validated record already renamed into place.
2892
+ await persistLarkSetupJournal({ ...journal, phase: 'validated' });
2893
+ await clearLarkSetupJournal(patchPath);
2894
+ }, runtime.profileLockOptions);
2895
+ }, runtime.profileLockOptions);
2896
+ process.stdout.write(`DSH profile ${args.profile} 的 Agent capability policy 已刷新;飞书应用、凭据和 owner 绑定未改动。\n`);
2897
+ return;
2898
+ }
2899
+ if (args.installServiceOnly) {
2900
+ if (process.platform === 'linux') {
2901
+ const prepared = await prepareResidentService({ dshHome, profile: args.profile });
2902
+ if (prepared?.enabledLinger)
2903
+ process.stdout.write('已为当前 Linux 用户启用 linger,注销 SSH 后服务仍会保持。\n');
2904
+ }
2905
+ const service = await withProfileSetupLock(patchPath, async () => {
2906
+ await recoverLarkSetupJournalUnlocked({
2778
2907
  patchPath,
2779
2908
  dshHome,
2780
2909
  profile: args.profile,
2781
- operation: 'refresh',
2782
- phase: 'candidate',
2783
- originalPatch,
2784
- updatedPatch,
2785
- installService: false,
2786
- transactionId: randomBytes(16).toString('hex'),
2787
- };
2788
- await persistLarkSetupJournal(journal);
2789
- try {
2790
- await atomicWrite(patchPath, updatedPatch);
2910
+ operations: recoveryOperations,
2911
+ });
2912
+ const databasePath = deliveryDatabasePathFromEffectiveProfile(await readEffectiveProfile(args.profile), dshHome);
2913
+ return withDeliveryOwnerSetupLock(databasePath, async () => {
2914
+ await assertNoOtherLarkProfileSharesDelivery({
2915
+ dshHome,
2916
+ profile: args.profile,
2917
+ databasePath,
2918
+ readEffectiveProfile,
2919
+ });
2920
+ const profileSnapshot = await readFile(patchPath, 'utf8');
2791
2921
  await validate(args.profile);
2792
- const validatedEffectiveProfile = await readEffectiveProfile(args.profile);
2793
- const validatedDatabasePath = deliveryDatabasePathFromEffectiveProfile(validatedEffectiveProfile, dshHome);
2922
+ const validatedDatabasePath = deliveryDatabasePathFromEffectiveProfile(await readEffectiveProfile(args.profile), dshHome);
2794
2923
  if (normalizeSetupResourcePath(validatedDatabasePath) !== normalizeSetupResourcePath(databasePath)) {
2795
- throw new Error('lark-channel setup: effective assistant-delivery database changed during refresh validation');
2796
- }
2797
- const validatedSemanticPatch = refreshLarkAgentPolicyFromEffective({
2798
- ...refreshInput,
2799
- effectiveProfile: validatedEffectiveProfile,
2800
- });
2801
- assertEffectiveManagedPolicyMatchesCandidate({
2802
- candidatePatch: validatedSemanticPatch,
2803
- effectiveProfile: validatedEffectiveProfile,
2804
- });
2805
- assertEffectiveManagedPolicyMatchesCandidate({
2806
- candidatePatch: updatedPatch,
2807
- effectiveProfile: validatedEffectiveProfile,
2808
- });
2809
- if (await readFile(patchPath, 'utf8') !== updatedPatch) {
2810
- throw new Error('lark-channel setup: refreshed profile changed during validation');
2924
+ throw new Error('lark-channel setup: effective assistant-delivery database changed during service validation');
2811
2925
  }
2812
- }
2813
- catch (error) {
2814
- if (await rollbackCandidatePatch({ patchPath, originalPatch, updatedPatch }) === 'conflict') {
2815
- throw new Error('lark-channel setup: rollback refused because the profile changed concurrently', { cause: error });
2926
+ if (await readFile(patchPath, 'utf8') !== profileSnapshot) {
2927
+ throw new Error('lark-channel setup: profile changed during resident service validation');
2816
2928
  }
2817
- await clearLarkSetupJournal(patchPath);
2818
- throw error;
2819
- }
2820
- // If this durable phase transition or journal removal reports an error,
2821
- // leave recovery to inspect the on-disk phase. Rolling back here could
2822
- // contradict a validated record already renamed into place.
2823
- await persistLarkSetupJournal({ ...journal, phase: 'validated' });
2824
- await clearLarkSetupJournal(patchPath);
2929
+ return installResidentService({ dshHome, profile: args.profile });
2930
+ }, runtime.profileLockOptions);
2825
2931
  }, runtime.profileLockOptions);
2826
- }, runtime.profileLockOptions);
2827
- process.stdout.write(`DSH profile ${args.profile} 的 Agent capability policy 已刷新;飞书应用、凭据和 owner 绑定未改动。\n`);
2828
- return;
2829
- }
2830
- if (args.installServiceOnly) {
2831
- if (process.platform === 'linux') {
2932
+ process.stdout.write(`DSH profile ${args.profile} 已由 ${service.kind} 启动并保持常驻。\n`
2933
+ + `状态:${service.statusCommand}\n日志:${service.logCommand}\n`);
2934
+ return;
2935
+ }
2936
+ const preferredCredentialProvider = credentialProviderForPlatform(process.platform);
2937
+ if (process.platform === 'linux' && args.manageService) {
2832
2938
  const prepared = await prepareResidentService({ dshHome, profile: args.profile });
2833
2939
  if (prepared?.enabledLinger)
2834
2940
  process.stdout.write('已为当前 Linux 用户启用 linger,注销 SSH 后服务仍会保持。\n');
2835
2941
  }
2836
- const service = await withProfileSetupLock(patchPath, async () => {
2942
+ let credentialProvider;
2943
+ if (runtime.preflightCredentialProvider === undefined) {
2944
+ const selection = await selectLarkCredentialProvider(preferredCredentialProvider, {
2945
+ dshHome,
2946
+ mode: args.linuxCredentialProvider,
2947
+ });
2948
+ credentialProvider = selection.provider;
2949
+ if (selection.fallbackReason !== undefined) {
2950
+ process.stdout.write('Linux Secret Service 当前不可用;向导已自动切换到当前用户 0600 私有凭据文件。'
2951
+ + '该文件未额外加密,同一 UID 与 root 可读取,但不会进入 profile、命令参数、环境变量或日志。\n');
2952
+ }
2953
+ }
2954
+ else {
2955
+ if (args.linuxCredentialProvider !== 'auto') {
2956
+ throw new Error('lark-channel setup: an injected credential preflight cannot be combined with an explicit Linux provider');
2957
+ }
2958
+ credentialProvider = await runtime.preflightCredentialProvider(preferredCredentialProvider)
2959
+ ?? preferredCredentialProvider;
2960
+ }
2961
+ // Resolve a prior crash before prompting the user or mutating a cloud app.
2962
+ // The mutation transaction repeats this check after registration to close
2963
+ // the gap against a different setup process that completed in the meantime.
2964
+ await withProfileSetupLock(patchPath, async () => {
2837
2965
  await recoverLarkSetupJournalUnlocked({
2838
2966
  patchPath,
2839
2967
  dshHome,
2840
2968
  profile: args.profile,
2841
2969
  operations: recoveryOperations,
2970
+ allowDeferredSecretServiceCleanup: credentialProvider === 'linux-protected-file',
2842
2971
  });
2843
2972
  const databasePath = deliveryDatabasePathFromEffectiveProfile(await readEffectiveProfile(args.profile), dshHome);
2844
- return withDeliveryOwnerSetupLock(databasePath, async () => {
2973
+ await withDeliveryOwnerSetupLock(databasePath, async () => {
2845
2974
  await assertNoOtherLarkProfileSharesDelivery({
2846
2975
  dshHome,
2847
2976
  profile: args.profile,
2848
2977
  databasePath,
2849
2978
  readEffectiveProfile,
2850
2979
  });
2851
- const profileSnapshot = await readFile(patchPath, 'utf8');
2852
- await validate(args.profile);
2853
- const validatedDatabasePath = deliveryDatabasePathFromEffectiveProfile(await readEffectiveProfile(args.profile), dshHome);
2854
- if (normalizeSetupResourcePath(validatedDatabasePath) !== normalizeSetupResourcePath(databasePath)) {
2855
- throw new Error('lark-channel setup: effective assistant-delivery database changed during service validation');
2856
- }
2857
- if (await readFile(patchPath, 'utf8') !== profileSnapshot) {
2858
- throw new Error('lark-channel setup: profile changed during resident service validation');
2859
- }
2860
- return installResidentService({ dshHome, profile: args.profile });
2861
2980
  }, runtime.profileLockOptions);
2862
2981
  }, runtime.profileLockOptions);
2863
- process.stdout.write(`DSH profile ${args.profile} 已由 ${service.kind} 启动并保持常驻。\n`
2864
- + `状态:${service.statusCommand}\n日志:${service.logCommand}\n`);
2865
- return;
2866
- }
2867
- const preferredCredentialProvider = credentialProviderForPlatform(process.platform);
2868
- if (process.platform === 'linux' && args.manageService) {
2869
- const prepared = await prepareResidentService({ dshHome, profile: args.profile });
2870
- if (prepared?.enabledLinger)
2871
- process.stdout.write('已为当前 Linux 用户启用 linger,注销 SSH 后服务仍会保持。\n');
2872
- }
2873
- let credentialProvider;
2874
- if (runtime.preflightCredentialProvider === undefined) {
2875
- const selection = await selectLarkCredentialProvider(preferredCredentialProvider, {
2876
- dshHome,
2877
- mode: args.linuxCredentialProvider,
2878
- });
2879
- credentialProvider = selection.provider;
2880
- if (selection.fallbackReason !== undefined) {
2881
- process.stdout.write('Linux Secret Service 当前不可用;向导已自动切换到当前用户 0600 私有凭据文件。'
2882
- + '该文件未额外加密,同一 UID 与 root 可读取,但不会进入 profile、命令参数、环境变量或日志。\n');
2982
+ let appId = args.appId;
2983
+ let createApp = args.createApp;
2984
+ if (appId === undefined && !createApp) {
2985
+ const prompts = createInterface({ input: process.stdin, output: process.stdout });
2986
+ try {
2987
+ appId = (await prompts.question('已有飞书 App ID(cli_...;直接回车则打开一键选择/创建):')).trim();
2988
+ createApp = appId.length === 0;
2989
+ }
2990
+ finally {
2991
+ prompts.close();
2992
+ }
2883
2993
  }
2884
- }
2885
- else {
2886
- if (args.linuxCredentialProvider !== 'auto') {
2887
- throw new Error('lark-channel setup: an injected credential preflight cannot be combined with an explicit Linux provider');
2994
+ let resolvedDomain = args.domain;
2995
+ let generatedSecret;
2996
+ if (createApp) {
2997
+ process.stdout.write(args.appId === undefined
2998
+ ? '\n将通过飞书官方设备授权选择已有应用或创建新应用,并增量申请最小权限。App Secret 不会显示或写入 profile。\n'
2999
+ : `\n将通过飞书官方设备授权更新已有应用 ${args.appId},并增量申请最小权限。App Secret 不会显示或写入 profile。\n`);
3000
+ const registration = await registerLarkApplication(args);
3001
+ appId = registration.client_id;
3002
+ resolvedDomain = registration.user_info?.tenant_brand ?? resolvedDomain;
3003
+ generatedSecret = registration.client_secret;
3004
+ process.stdout.write(`应用 ${appId} 已完成授权;凭据将在 profile 事务中暂存并激活。\n`);
2888
3005
  }
2889
- credentialProvider = await runtime.preflightCredentialProvider(preferredCredentialProvider)
2890
- ?? preferredCredentialProvider;
2891
- }
2892
- // Resolve a prior crash before prompting the user or mutating a cloud app.
2893
- // The mutation transaction repeats this check after registration to close
2894
- // the gap against a different setup process that completed in the meantime.
2895
- await withProfileSetupLock(patchPath, async () => {
2896
- await recoverLarkSetupJournalUnlocked({
2897
- patchPath,
3006
+ else {
3007
+ if (appId === undefined || !/^cli_[0-9a-fA-F]{16}$/u.test(appId)) {
3008
+ throw new Error('lark-channel setup: invalid App ID');
3009
+ }
3010
+ process.stdout.write(`请确认已有应用已开启机器人、im:message 与 im:resource 权限、长连接事件 im.message.receive_v1,且版本已发布。\n`
3011
+ + `控制台:https://open.feishu.cn/app\n`);
3012
+ }
3013
+ if (appId === undefined)
3014
+ throw new Error('lark-channel setup: Feishu did not return an App ID');
3015
+ const configuredAppId = appId;
3016
+ let installedService;
3017
+ await executeLarkSetupProfileTransaction({
3018
+ args,
2898
3019
  dshHome,
2899
- profile: args.profile,
2900
- operations: recoveryOperations,
2901
- allowDeferredSecretServiceCleanup: credentialProvider === 'linux-protected-file',
2902
- });
2903
- const databasePath = deliveryDatabasePathFromEffectiveProfile(await readEffectiveProfile(args.profile), dshHome);
2904
- await withDeliveryOwnerSetupLock(databasePath, async () => {
2905
- await assertNoOtherLarkProfileSharesDelivery({
2906
- dshHome,
2907
- profile: args.profile,
2908
- databasePath,
3020
+ patchPath,
3021
+ application: { appId: configuredAppId, domain: resolvedDomain },
3022
+ credentialProvider,
3023
+ ...(runtime.profileLockOptions === undefined ? {} : { profileLockOptions: runtime.profileLockOptions }),
3024
+ operations: {
3025
+ async storeCredential(locator) {
3026
+ const service = locator.provider === 'windows-dpapi'
3027
+ ? `dsh/lark/${args.profile}/${args.account}/versions/windows-dpapi`
3028
+ : locator.provider === 'linux-protected-file'
3029
+ ? `dsh/lark/${args.profile}/${args.account}/versions/linux-protected-file`
3030
+ : locator.service;
3031
+ const credentialPath = locator.provider === 'windows-dpapi'
3032
+ || locator.provider === 'linux-protected-file' ? locator.path : undefined;
3033
+ if (generatedSecret === undefined) {
3034
+ await storeSecret(credentialProvider, service, args.account, credentialPath);
3035
+ }
3036
+ else {
3037
+ await storeGeneratedSecret(credentialProvider, service, args.account, generatedSecret, credentialPath);
3038
+ }
3039
+ process.stdout.write(`候选凭据已暂存到 ${credentialProvider};旧凭据仍保持有效。\n`);
3040
+ },
3041
+ readCredential(locator) {
3042
+ const service = locator.provider === 'windows-dpapi'
3043
+ ? `dsh/lark/${args.profile}/${args.account}/versions/windows-dpapi`
3044
+ : locator.provider === 'linux-protected-file'
3045
+ ? `dsh/lark/${args.profile}/${args.account}/versions/linux-protected-file`
3046
+ : locator.service;
3047
+ return readSecret(credentialProvider, service, args.account, locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file'
3048
+ ? locator.path
3049
+ : undefined);
3050
+ },
2909
3051
  readEffectiveProfile,
2910
- });
2911
- }, runtime.profileLockOptions);
2912
- }, runtime.profileLockOptions);
2913
- let appId = args.appId;
2914
- let createApp = args.createApp;
2915
- if (appId === undefined && !createApp) {
2916
- const prompts = createInterface({ input: process.stdin, output: process.stdout });
2917
- try {
2918
- appId = (await prompts.question('已有飞书 App ID(cli_...;直接回车则打开一键选择/创建):')).trim();
2919
- createApp = appId.length === 0;
3052
+ async discoverOwner(ownerInput) {
3053
+ const phrase = `DSH-CONNECT-${randomBytes(4).toString('hex').toUpperCase()}`;
3054
+ const transport = createOfficialLarkTransport({
3055
+ appId: ownerInput.appId,
3056
+ appSecret: ownerInput.appSecret,
3057
+ domain: ownerInput.domain,
3058
+ handshakeTimeoutMs: 15_000,
3059
+ requestTimeoutMs: 30_000,
3060
+ imageDownloadTimeoutMs: 30_000,
3061
+ });
3062
+ return discoverOwner(transport, phrase, ownerInput.account, ownerInput.tenant, ownerInput.timeoutMs);
3063
+ },
3064
+ ...(args.manageService ? {
3065
+ async afterCommit() {
3066
+ installedService = await installResidentService({ dshHome, profile: args.profile });
3067
+ },
3068
+ } : {}),
3069
+ },
3070
+ });
3071
+ if (args.manageService) {
3072
+ const service = installedService;
3073
+ if (service === undefined)
3074
+ throw new Error('lark-channel setup: resident service installation did not complete');
3075
+ process.stdout.write(`\n飞书 channel 已写入 profile ${args.profile},owner 已精确绑定。\n`
3076
+ + `DSH Host 已由 ${service.kind} 启动并保持常驻,无需再手动运行 dsh web。\n`
3077
+ + `状态:${service.statusCommand}\n日志:${service.logCommand}\n`
3078
+ + `健康检查:在 Web 会话中调用 assistant_health。\n`);
2920
3079
  }
2921
- finally {
2922
- prompts.close();
3080
+ else {
3081
+ process.stdout.write(`\n飞书 channel 已写入 profile ${args.profile},owner 已精确绑定。\n`
3082
+ + `本次跳过常驻服务;手动启动:dsh --profile ${args.profile} --no-open\n`
3083
+ + `健康检查:在 Web 会话中调用 assistant_health。\n`);
2923
3084
  }
2924
- }
2925
- let resolvedDomain = args.domain;
2926
- let generatedSecret;
2927
- if (createApp) {
2928
- process.stdout.write(args.appId === undefined
2929
- ? '\n将通过飞书官方设备授权选择已有应用或创建新应用,并增量申请最小权限。App Secret 不会显示或写入 profile。\n'
2930
- : `\n将通过飞书官方设备授权更新已有应用 ${args.appId},并增量申请最小权限。App Secret 不会显示或写入 profile。\n`);
2931
- const registration = await registerLarkApplication(args);
2932
- appId = registration.client_id;
2933
- resolvedDomain = registration.user_info?.tenant_brand ?? resolvedDomain;
2934
- generatedSecret = registration.client_secret;
2935
- process.stdout.write(`应用 ${appId} 已完成授权;凭据将在 profile 事务中暂存并激活。\n`);
2936
- }
2937
- else {
2938
- if (appId === undefined || !/^cli_[0-9a-fA-F]{16}$/u.test(appId)) {
2939
- throw new Error('lark-channel setup: invalid App ID');
2940
- }
2941
- process.stdout.write(`请确认已有应用已开启机器人、im:message 与 im:resource 权限、长连接事件 im.message.receive_v1,且版本已发布。\n`
2942
- + `控制台:https://open.feishu.cn/app\n`);
2943
- }
2944
- if (appId === undefined)
2945
- throw new Error('lark-channel setup: Feishu did not return an App ID');
2946
- const configuredAppId = appId;
2947
- let installedService;
2948
- await executeLarkSetupProfileTransaction({
2949
- args,
2950
- dshHome,
2951
- patchPath,
2952
- application: { appId: configuredAppId, domain: resolvedDomain },
2953
- credentialProvider,
2954
- ...(runtime.profileLockOptions === undefined ? {} : { profileLockOptions: runtime.profileLockOptions }),
2955
- operations: {
2956
- async storeCredential(locator) {
2957
- const service = locator.provider === 'windows-dpapi'
2958
- ? `dsh/lark/${args.profile}/${args.account}/versions/windows-dpapi`
2959
- : locator.provider === 'linux-protected-file'
2960
- ? `dsh/lark/${args.profile}/${args.account}/versions/linux-protected-file`
2961
- : locator.service;
2962
- const credentialPath = locator.provider === 'windows-dpapi'
2963
- || locator.provider === 'linux-protected-file' ? locator.path : undefined;
2964
- if (generatedSecret === undefined) {
2965
- await storeSecret(credentialProvider, service, args.account, credentialPath);
2966
- }
2967
- else {
2968
- await storeGeneratedSecret(credentialProvider, service, args.account, generatedSecret, credentialPath);
2969
- }
2970
- process.stdout.write(`候选凭据已暂存到 ${credentialProvider};旧凭据仍保持有效。\n`);
2971
- },
2972
- readCredential(locator) {
2973
- const service = locator.provider === 'windows-dpapi'
2974
- ? `dsh/lark/${args.profile}/${args.account}/versions/windows-dpapi`
2975
- : locator.provider === 'linux-protected-file'
2976
- ? `dsh/lark/${args.profile}/${args.account}/versions/linux-protected-file`
2977
- : locator.service;
2978
- return readSecret(credentialProvider, service, args.account, locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file'
2979
- ? locator.path
2980
- : undefined);
2981
- },
2982
- readEffectiveProfile,
2983
- async discoverOwner(ownerInput) {
2984
- const phrase = `DSH-CONNECT-${randomBytes(4).toString('hex').toUpperCase()}`;
2985
- const transport = createOfficialLarkTransport({
2986
- appId: ownerInput.appId,
2987
- appSecret: ownerInput.appSecret,
2988
- domain: ownerInput.domain,
2989
- handshakeTimeoutMs: 15_000,
2990
- imageDownloadTimeoutMs: 30_000,
2991
- });
2992
- return discoverOwner(transport, phrase, ownerInput.account, ownerInput.tenant, ownerInput.timeoutMs);
2993
- },
2994
- ...(args.manageService ? {
2995
- async afterCommit() {
2996
- installedService = await installResidentService({ dshHome, profile: args.profile });
2997
- },
2998
- } : {}),
2999
- },
3000
3085
  });
3001
- if (args.manageService) {
3002
- const service = installedService;
3003
- if (service === undefined)
3004
- throw new Error('lark-channel setup: resident service installation did not complete');
3005
- process.stdout.write(`\n飞书 channel 已写入 profile ${args.profile},owner 已精确绑定。\n`
3006
- + `DSH Host 已由 ${service.kind} 启动并保持常驻,无需再手动运行 dsh web。\n`
3007
- + `状态:${service.statusCommand}\n日志:${service.logCommand}\n`
3008
- + `健康检查:在 Web 会话中调用 assistant_health。\n`);
3009
- }
3010
- else {
3011
- process.stdout.write(`\n飞书 channel 已写入 profile ${args.profile},owner 已精确绑定。\n`
3012
- + `本次跳过常驻服务;手动启动:dsh --profile ${args.profile} --no-open\n`
3013
- + `健康检查:在 Web 会话中调用 assistant_health。\n`);
3014
- }
3015
3086
  }
3016
3087
  if (process.argv[1] !== undefined && isMainEntry(import.meta.url, process.argv[1])) {
3017
3088
  void runLarkSetup().catch(error => {