huaweicloud-devkit 1.1.12 → 1.1.13-next.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.
@@ -1,7 +1,9 @@
1
1
  import { getCredentials } from './hwlink-api.mjs';
2
2
  import { getProxyDispatcher } from '../proxy/proxy-agent.mjs';
3
3
  import { cacheUserHash } from '../telemetry/telemetry.mjs';
4
+ import { detectAgent } from '../telemetry/agent-detect.mjs';
4
5
  import { readInstalledVersion } from '../update-check.mjs';
6
+ import { pinnedTemplateFor } from './sandbox-pool.mjs';
5
7
 
6
8
  function getHdkitBaseUrl() {
7
9
  return process.env.HDKITSERVICE_ENDPOINT || 'https://devkit.huaweicloud.com/rest/developer/server/hdkitservice/';
@@ -21,6 +23,8 @@ async function hdkitRequest(method, path, body, timeoutMs = 300000) {
21
23
  'X-HW-AK': ak,
22
24
  'X-HW-SK': sk,
23
25
  'X-HW-Client-Version': readInstalledVersion() || '0.0.0',
26
+ // #1040: 对外请求统一携带 X-HW-Agent(值=detectAgent 结果 harness)
27
+ 'X-HW-Agent': detectAgent().harness,
24
28
  };
25
29
  if (securitytoken) {
26
30
  headers['X-HW-Security-Token'] = securitytoken;
@@ -75,6 +79,43 @@ async function hdkitRequest(method, path, body, timeoutMs = 300000) {
75
79
  ],
76
80
  };
77
81
  }
82
+ // 429 双错误码结构化处理(两池配额契约):配额满(用户侧,重试无效)与
83
+ // 池容量不足(平台侧,错峰重试)给出差异化引导;携带服务端 Retry-After
84
+ if (code === 'HDKIT_QUOTA_EXCEEDED') {
85
+ err.retryAfter = resp.headers.get('Retry-After') || undefined;
86
+ err.remediation = {
87
+ hint: '沙箱并发配额已满(每账号最多 5 台、每类型 1 台),重试无效:',
88
+ steps: [
89
+ '确认已有实例是否够用:connect 对同池实例自动复用,无需重复创建',
90
+ '释放不再使用的沙箱(控制台或平台回收)后重试',
91
+ '如需两种生命周期语义并存:ephemeral 与 persistent 各可持有 1 台',
92
+ ],
93
+ };
94
+ }
95
+ if (code === 'HDKIT_CAPACITY_INSUFFICIENT') {
96
+ err.retryAfter = resp.headers.get('Retry-After') || undefined;
97
+ err.remediation = {
98
+ hint: '平台池容量暂时不足(非账号问题):',
99
+ steps: [
100
+ '稍后错峰重试(devkit提供的免费资源紧张,建议错峰使用)',
101
+ '急需使用时可尝试另一 pool_type(另一池可能有余量)',
102
+ ],
103
+ };
104
+ }
105
+ // 烧核播报(缺口#5 代码层):账号核心时长耗尽——上游码 HD.97520422 经
106
+ // hdkitservice 以 HDKIT_UPSTREAM_ERROR 包装透传,客户端按消息内错误码识别。
107
+ // 仅在前面的分支未设置 remediation 时生效(else-if 语义:烧核不覆盖 429 双码/凭证指引)
108
+ if (!err.remediation && String(data.message || '').includes('HD.97520422')) {
109
+ err.coreHoursExhausted = true;
110
+ err.remediation = {
111
+ hint: '账号核心时长(核时)已耗尽,沙箱无法继续运行:',
112
+ steps: [
113
+ '核时按沙箱运行时长消耗,当前账号余额为 0',
114
+ '更换有核时余额的账号,或等待/申请核时补充',
115
+ '详见华为云开发者空间核心时长说明',
116
+ ],
117
+ };
118
+ }
78
119
  throw err;
79
120
  }
80
121
 
@@ -104,6 +145,13 @@ export async function hdkitConnect(options = {}) {
104
145
  if (options.git) body.git = options.git;
105
146
  if (options.template_id) body.template_id = options.template_id;
106
147
  if (options.flavor_id) body.flavor_id = options.flavor_id;
148
+ if (options.pool_type) {
149
+ // 两池:pool_type 由服务端权威映射(不传默认 ephemeral)。
150
+ // 可选运维钉扎:配置 HW_POOL_TEMPLATE_* 时附带映射模板(服务端与老服务端均按显式模板处理)
151
+ body.pool_type = options.pool_type;
152
+ const pinned = pinnedTemplateFor(options.pool_type);
153
+ if (pinned && !body.template_id) body.template_id = pinned;
154
+ }
107
155
 
108
156
  return await hdkitRequest('POST', 'connect', body);
109
157
  }
@@ -0,0 +1,104 @@
1
+ // 两池沙箱(pool_type)客户端支持。
2
+ // 机制(定稿):池 = 平台预置模板,客户端传 pool_type(ephemeral/persistent),
3
+ // 服务端权威映射 template_id;显式 template_id 优先(运维钉扎场景)。
4
+ // - 非持久池(默认):闲置 30min 自动关机 + 1h 删除(平台定时任务,已实测生效)
5
+ // - 持久池:EVS 数据盘跨关机保留,闲置回收保留期上限 7 天
6
+ // - 配额:每用户每类型 1 台(同类复用),账号并发上限 5
7
+
8
+ const POOL_TYPES = new Set(['ephemeral', 'persistent']);
9
+
10
+ /** workspaceId → poolType 缓存:exec/upload/deploy 等工具返回值据此附带池上下文 */
11
+ const workspacePool = new Map();
12
+
13
+ /**
14
+ * 校验并归一化 pool_type;未传时返回 null(交由服务端默认 ephemeral,不在客户端伪造语义)。
15
+ * @throws Error 非法值(ephemeral/persistent 之外)
16
+ */
17
+ export function validatePoolType(raw) {
18
+ if (raw === undefined || raw === null || raw === '') return null;
19
+ const normalized = String(raw).trim().toLowerCase();
20
+ if (!POOL_TYPES.has(normalized)) {
21
+ throw new Error(
22
+ `invalid pool_type "${raw}" (expected ephemeral or persistent). ` +
23
+ 'ephemeral = 非持久沙箱(闲置 30min 自动关机、1h 删除);persistent = 持久沙箱(数据盘保留,闲置回收上限 7 天)',
24
+ );
25
+ }
26
+ return normalized;
27
+ }
28
+
29
+ /**
30
+ * 运维钉扎(可选):设置 HW_POOL_TEMPLATE_EPHEMERAL/PERSISTENT 后,客户端把映射出的
31
+ * template_id 与 pool_type 一并上送——服务端(及未升级的老服务端)按显式模板处理。
32
+ * 日常部署无需配置:模板 ID 由服务端 POOL_TEMPLATE_* 环境变量权威维护。
33
+ */
34
+ export function pinnedTemplateFor(poolType) {
35
+ if (poolType === 'ephemeral') return process.env.HW_POOL_TEMPLATE_EPHEMERAL || '';
36
+ if (poolType === 'persistent') return process.env.HW_POOL_TEMPLATE_PERSISTENT || '';
37
+ return '';
38
+ }
39
+
40
+ /** 记录 workspace 的池归属(来自服务端 connect 响应的 pool_type 回显) */
41
+ export function rememberWorkspacePool(workspaceId, poolType) {
42
+ if (workspaceId && poolType) workspacePool.set(workspaceId, poolType);
43
+ }
44
+
45
+ /** 清理 workspace 池缓存(测试用) */
46
+ export function forgetWorkspacePool(workspaceId) {
47
+ if (workspaceId === undefined) workspacePool.clear();
48
+ else workspacePool.delete(workspaceId);
49
+ }
50
+
51
+ /**
52
+ * exec/upload/deploy 等工具返回值的池上下文(缺口#2 模式提示)。
53
+ * 未记录池归属(老服务端/自定义模板)时返回 undefined,不附加任何字段。
54
+ */
55
+ export function poolContextFor(workspaceId) {
56
+ const poolType = workspacePool.get(workspaceId);
57
+ if (poolType === 'ephemeral') {
58
+ return {
59
+ poolMode: 'ephemeral',
60
+ persistenceHint: '非持久沙箱:闲置 30 分钟自动关机、1 小时自动删除,重要数据请及时保存或迁出',
61
+ };
62
+ }
63
+ if (poolType === 'persistent') {
64
+ return {
65
+ poolMode: 'persistent',
66
+ persistenceHint: '持久沙箱:数据盘跨关机保留,闲置回收保留期最长 7 天',
67
+ };
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ /**
73
+ * connect 结果池富化(缺口#1 TTL 播报)。
74
+ * 仅当服务端回显 pool_type 才播报(两种状态制:服务端未确认池语义不编造)。
75
+ * 钉扎/自定义模板(服务端回显 null)不播报。
76
+ * 读取做 pool_type/poolType 双读兜底(与 devStageId 双读同型,防 wire 命名漂移)。
77
+ */
78
+ export function enrichConnectResult(result) {
79
+ if (!result || typeof result !== 'object') return result;
80
+ const poolType = result.pool_type || result.poolType;
81
+ if (poolType === 'ephemeral') {
82
+ result.pool = {
83
+ pool_type: 'ephemeral',
84
+ lifecycle: 'idle 30min auto-shutdown + 1h auto-delete (platform scheduled task, verified)',
85
+ ttl_hint:
86
+ '非持久沙箱:闲置 30 分钟将自动关机、闲置 1 小时将自动删除(平台定时任务已生效)。' +
87
+ '重要数据请及时保存或迁出;需要跨会话保留数据请改用 pool_type=persistent。',
88
+ };
89
+ } else if (poolType === 'persistent') {
90
+ result.pool = {
91
+ pool_type: 'persistent',
92
+ lifecycle: 'EVS data disk retained; idle reclaim within 7 days max',
93
+ ttl_hint: '持久沙箱:数据盘跨关机保留,闲置回收保留期最长 7 天;长期不用请自行导出数据。',
94
+ };
95
+ }
96
+ return result;
97
+ }
98
+
99
+ /** 池信息字段并入既有对象结果(非对象结果原样返回) */
100
+ export function withPoolContext(workspaceId, result) {
101
+ const ctx = poolContextFor(workspaceId);
102
+ if (!ctx || !result || typeof result !== 'object') return result;
103
+ return { ...result, ...ctx };
104
+ }
@@ -75,6 +75,13 @@ function configRoot(target = 'opencode') {
75
75
  return join(home, '.config', target);
76
76
  }
77
77
 
78
+ // Must match telemetry.mjs GLOBAL_TELEMETRY_DIR root: HUAWEICLOUD_DEVKIT_HOME
79
+ // overrides homedir() so sandbox/multi-HOME environments stay consistent (#1014).
80
+ function devkitTelemetryDir() {
81
+ const home = (process.env.HUAWEICLOUD_DEVKIT_HOME || '').trim() || homedir();
82
+ return join(home, '.huaweicloud-devkit', 'telemetry');
83
+ }
84
+
78
85
  function opencodeSkillsDir() {
79
86
  return join(configRoot('opencode'), 'skills');
80
87
  }
@@ -1814,7 +1821,7 @@ function deployWorkBuddyHooks() {
1814
1821
 
1815
1822
  // 4. Ensure telemetry output directories exist
1816
1823
  mkdirSync(join(workbuddyPluginsDir(), 'telemetry'), { recursive: true });
1817
- mkdirSync(join(homedir(), '.huaweicloud-devkit', 'telemetry'), { recursive: true });
1824
+ mkdirSync(devkitTelemetryDir(), { recursive: true });
1818
1825
  }
1819
1826
 
1820
1827
  // ── AtomCode hooks deployment ────────────────────────────────
@@ -1982,10 +1989,10 @@ function uninstallWorkBuddy() {
1982
1989
  console.log(` Removed hook tracker: ${trackerPath}`);
1983
1990
  }
1984
1991
 
1985
- const devkitTelemetryDir = join(homedir(), '.huaweicloud-devkit', 'telemetry');
1986
- if (existsSync(devkitTelemetryDir)) {
1987
- rmSync(devkitTelemetryDir, { recursive: true, force: true });
1988
- console.log(` Removed telemetry cache: ${devkitTelemetryDir}`);
1992
+ const devkitTelemetryPath = devkitTelemetryDir();
1993
+ if (existsSync(devkitTelemetryPath)) {
1994
+ rmSync(devkitTelemetryPath, { recursive: true, force: true });
1995
+ console.log(` Removed telemetry cache: ${devkitTelemetryPath}`);
1989
1996
  }
1990
1997
  }
1991
1998
 
@@ -1,24 +1,31 @@
1
- import { AGENTS, matchAgent, detectVersion, installSegment } from './agent-registry.mjs';
1
+ import { AGENTS, matchAgent, detectVersion } from './agent-registry.mjs';
2
2
 
3
3
  /**
4
- * 仅返回 harness 字符串(或 null),不返回 version。
5
- * 用于简单场景(如 keepalive 判断、测试断言)。
4
+ * 仅返回 harness 字符串,不返回 version。
5
+ * #1040: 白名单 / AGENT_HARNESS 未命中时一律返回 'unknown'——
6
+ * 不再透传 clientInfo.name(startup、voucher-claim*、test* 等探针类 clientName 同样归 unknown),
7
+ * 也不拼接 installSegment,避免 agent 名出现 '|' 污染。
6
8
  */
7
9
  export function detectAgentHarness(clientInfo = {}) {
8
- if (process.env.AGENT_HARNESS) return process.env.AGENT_HARNESS;
10
+ const envHarness = process.env.AGENT_HARNESS;
11
+ if (envHarness && AGENTS.some((a) => a.id === envHarness)) return envHarness;
9
12
  for (const agent of AGENTS) {
10
13
  if (matchAgent(agent, clientInfo)) return agent.id;
11
14
  }
12
- return clientInfo.name || null;
15
+ return 'unknown';
13
16
  }
14
17
 
15
18
  export function detectAgent(clientInfo = {}) {
16
- if (process.env.AGENT_HARNESS) {
17
- const cfg = AGENTS.find((a) => a.id === process.env.AGENT_HARNESS) || null;
18
- return {
19
- harness: process.env.AGENT_HARNESS,
20
- version: detectVersion(cfg ? cfg.version : null) || clientInfo.version || '0.0.0',
21
- };
19
+ // AGENT_HARNESS 仅在命中白名单时生效;未命中视为不可信输入,不透传任意环境值。
20
+ const envHarness = process.env.AGENT_HARNESS;
21
+ if (envHarness) {
22
+ const cfg = AGENTS.find((a) => a.id === envHarness) || null;
23
+ if (cfg) {
24
+ return {
25
+ harness: envHarness,
26
+ version: detectVersion(cfg.version) || clientInfo.version || '0.0.0',
27
+ };
28
+ }
22
29
  }
23
30
 
24
31
  for (const agent of AGENTS) {
@@ -30,11 +37,9 @@ export function detectAgent(clientInfo = {}) {
30
37
  }
31
38
  }
32
39
 
33
- const seg = installSegment();
34
- const base = clientInfo.name || 'unknown';
35
- const harness = seg ? `${base}|${seg}` : base;
40
+ // #1040: 兜底收敛 'unknown'——安装位置信息走遥测独立字段 installSegment,不进入 agent 名。
36
41
  return {
37
- harness: harness.length > 32 ? harness.slice(0, 32) : harness,
42
+ harness: 'unknown',
38
43
  version: clientInfo.version || '0.0.0',
39
44
  };
40
45
  }
@@ -10,6 +10,10 @@ const selfPath = (() => {
10
10
  }
11
11
  })();
12
12
 
13
+ /**
14
+ * #1040: 安装位置段——仅用于遥测的独立安装位置字段(telemetry 事件 installSegment),
15
+ * 不进入 agent 名/harness;AGENTS 白名单与 matchAgent 规则不受安装位置影响。
16
+ */
13
17
  export function installSegment() {
14
18
  const parts = selfPath.split('/').filter(Boolean);
15
19
  const srcIdx = parts.lastIndexOf('src');
@@ -14,6 +14,7 @@ import { createHash, randomUUID } from 'node:crypto';
14
14
  import { fileURLToPath } from 'node:url';
15
15
 
16
16
  import { fetchWithProxy } from '../proxy/proxy-agent.mjs';
17
+ import { installSegment } from './agent-registry.mjs';
17
18
 
18
19
  const __filename = fileURLToPath(import.meta.url);
19
20
  const __dirname = dirname(__filename);
@@ -68,7 +69,7 @@ const MAX_RETRIES = 3;
68
69
  const DEFAULT_ENDPOINT = 'https://devkit.huaweicloud.com/rest/developer/server/hdkitservice/telemetry/events';
69
70
 
70
71
  let eventQueue = [];
71
- let isFlushing = false;
72
+ let currentFlush = null;
72
73
  let flushTimer = null;
73
74
  let lastCheckedDate = '';
74
75
  let installId = null;
@@ -207,6 +208,9 @@ function buildEvent(raw) {
207
208
  os: osTypeStr,
208
209
  osVersion: osVersionStr,
209
210
  };
211
+ // #1040: 安装位置独立字段——原拼接进 harness 的 installSegment 改为单列上报。
212
+ const seg = installSegment();
213
+ if (seg) event.installSegment = sanitizeValue(seg);
210
214
  const cap = raw.capability || capabilityFromKey(raw.key);
211
215
  if (cap) event.capability = cap;
212
216
  return event;
@@ -214,7 +218,9 @@ function buildEvent(raw) {
214
218
 
215
219
  export function enqueueEvent(raw) {
216
220
  if (!isTelemetryEnabled()) return;
217
- if (!installId) return;
221
+ // #1014: installId 未初始化时(如未走 initialize 直接 tools/call)不再丢弃事件,
222
+ // 惰性恢复,避免 telemetry_event 缺记录。
223
+ if (!installId) installId = generateOrRecoverInstallId();
218
224
 
219
225
  ingestHookEvents();
220
226
 
@@ -324,10 +330,37 @@ export function clearUserHash() {
324
330
  } catch {}
325
331
  }
326
332
 
327
- function flushEvents() {
328
- if (isFlushing) return;
329
- if (eventQueue.length === 0) return;
330
- isFlushing = true;
333
+ // #1014: flush 前兜底补齐队列事件的缺失身份字段——
334
+ // userHash 在 enqueue 之后才解析到(领券响应 / user-hash 文件)时,
335
+ // 已入队事件的 userHash 仍为 null,这里统一回填,降低 user_hash null 占比。
336
+ function backfillIdentityFields() {
337
+ try {
338
+ if (!installId) installId = generateOrRecoverInstallId();
339
+ if (!userHash) loadUserHash();
340
+ for (const event of eventQueue) {
341
+ if (!event.installId && installId) event.installId = installId;
342
+ if (event.userHash == null && userHash) event.userHash = userHash;
343
+ }
344
+ } catch (_) {
345
+ debugLog('BACKFILL identity fields failed');
346
+ }
347
+ }
348
+
349
+ // #1014: flush 可被 await(P0 领券类工具同步上报);
350
+ // 并发调用复用同一次 in-flight flush(currentFlush 声明于模块顶部)。
351
+ export function flushEvents() {
352
+ if (currentFlush) return currentFlush;
353
+ if (eventQueue.length === 0) return Promise.resolve();
354
+ currentFlush = performFlush().finally(() => {
355
+ currentFlush = null;
356
+ });
357
+ return currentFlush;
358
+ }
359
+
360
+ async function performFlush() {
361
+ // flush 前统一回填身份字段(含 userHash 重解析,#1014):
362
+ // 入队时身份未解析的事件此前会永久携带 null userHash。
363
+ backfillIdentityFields();
331
364
 
332
365
  const batch = eventQueue.splice(0, BATCH_SIZE);
333
366
  const keys = batch.map((e) => e.key).join(',');
@@ -339,34 +372,72 @@ function flushEvents() {
339
372
  const controller = new AbortController();
340
373
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
341
374
 
342
- fetchWithProxy(endpoint, {
343
- method: 'POST',
344
- headers: { 'Content-Type': 'application/json' },
345
- body: JSON.stringify(batch.map(({ _retries, ...rest }) => rest)),
346
- signal: controller.signal,
347
- })
348
- .then((resp) => {
349
- clearTimeout(timer);
350
- debugLog(`POST status=${resp.status} events=${batch.length}`);
351
- if (resp.ok) {
352
- for (const event of batch) {
353
- if (event.key === 'dau:active_today') touchFile(dauStampPath());
354
- if (event.key === 'plugin:install') touchFile(installStampPath());
355
- if (event.key === 'plugin:first_use') touchFile(firstUseStampPath());
356
- }
357
- } else if (resp.status >= 400 && resp.status < 500) {
358
- debugLog(`POST status=${resp.status} dropping ${batch.length} events (client error)`);
359
- } else {
360
- requeueEvents(batch);
375
+ try {
376
+ const resp = await fetchWithProxy(endpoint, {
377
+ method: 'POST',
378
+ // #1040: 对外上报统一携带 X-HW-Agent(值=detectAgent 结果 harness,initTelemetry 缓存)
379
+ headers: { 'Content-Type': 'application/json', 'X-HW-Agent': agentHarness || 'unknown' },
380
+ body: JSON.stringify(batch.map(({ _retries, ...rest }) => rest)),
381
+ signal: controller.signal,
382
+ });
383
+ clearTimeout(timer);
384
+ debugLog(`POST status=${resp.status} events=${batch.length}`);
385
+ if (resp.ok) {
386
+ for (const event of batch) {
387
+ if (event.key === 'dau:active_today') touchFile(dauStampPath());
388
+ if (event.key === 'plugin:install') touchFile(installStampPath());
389
+ if (event.key === 'plugin:first_use') touchFile(firstUseStampPath());
361
390
  }
362
- isFlushing = false;
363
- })
364
- .catch((error) => {
365
- clearTimeout(timer);
366
- debugLog(`POST FAIL err=${error.message} events=${batch.length}`);
391
+ } else if (resp.status >= 400 && resp.status < 500) {
392
+ debugLog(`POST status=${resp.status} dropping ${batch.length} events (client error)`);
393
+ } else {
367
394
  requeueEvents(batch);
368
- isFlushing = false;
395
+ }
396
+ } catch (error) {
397
+ clearTimeout(timer);
398
+ debugLog(`POST FAIL err=${error.message} events=${batch.length}`);
399
+ requeueEvents(batch);
400
+ }
401
+ }
402
+
403
+ // #1014 P1: 排空整个队列(退出兜底用,单次 flush 只发一个 batch)。
404
+ export async function flushAllEvents() {
405
+ const maxBatches = Math.ceil(MAX_QUEUE_SIZE / BATCH_SIZE) + 1;
406
+ for (let i = 0; i < maxBatches && eventQueue.length > 0; i++) {
407
+ await flushEvents();
408
+ }
409
+ }
410
+
411
+ const EXIT_FLUSH_TIMEOUT_MS = 3000;
412
+ let exitFlushRegistered = false;
413
+
414
+ // #1014 P1: SIGTERM/SIGINT 退出兜底 flush(有界超时,避免阻塞退出)。
415
+ // 返回反注册函数(仅测试使用)。
416
+ export function registerExitFlush() {
417
+ if (exitFlushRegistered) return () => {};
418
+ exitFlushRegistered = true;
419
+ const flushAndExit = () => {
420
+ if (eventQueue.length === 0) {
421
+ // eslint-disable-next-line n/no-process-exit -- signal shutdown with nothing to flush
422
+ process.exit(0);
423
+ }
424
+ const cap = new Promise((resolve) => {
425
+ const t = setTimeout(resolve, EXIT_FLUSH_TIMEOUT_MS);
426
+ t.unref?.();
427
+ });
428
+ const done = flushAllEvents().catch(() => {});
429
+ Promise.race([done, cap]).finally(() => {
430
+ // eslint-disable-next-line n/no-process-exit -- signal shutdown after bounded exit flush
431
+ process.exit(0);
369
432
  });
433
+ };
434
+ process.once('SIGTERM', flushAndExit);
435
+ process.once('SIGINT', flushAndExit);
436
+ return () => {
437
+ process.removeListener('SIGTERM', flushAndExit);
438
+ process.removeListener('SIGINT', flushAndExit);
439
+ exitFlushRegistered = false;
440
+ };
370
441
  }
371
442
 
372
443
  function requeueEvents(batch) {
@@ -390,6 +461,9 @@ export function initTelemetry({ harness, version }) {
390
461
 
391
462
  loadUserHash();
392
463
 
464
+ // #1014 P1: SIGTERM/SIGINT 退出兜底 flush(队列空时立即退出,零开销)。
465
+ registerExitFlush();
466
+
393
467
  if (!isTelemetryEnabled()) return;
394
468
 
395
469
  if (!stampExists(installStampPath()) && !existsSync(installCounterPath())) {