skillrepo 4.3.0 → 4.5.0

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,15 +1,24 @@
1
1
  /**
2
- * Unit/integration tests for src/commands/list.mjs (PR2 of #646).
2
+ * Unit/integration tests for src/commands/list.mjs.
3
+ *
4
+ * Originally covered the table render + --json basics (#679, PR2 of
5
+ * #646). Extended in #1555 with per-row drift state coverage:
6
+ * - All four states (current / stale / missing / edited)
7
+ * - Worst-state-wins rollup across detected vendors
8
+ * - Library footer (4 ETag/last-sync cases)
9
+ * - --json shape with new `state` + `placements[]` fields
3
10
  */
4
11
 
5
12
  import { describe, it, beforeEach, afterEach } from "node:test";
6
13
  import assert from "node:assert/strict";
7
- import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
14
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
8
15
  import { join } from "node:path";
9
16
  import { tmpdir } from "node:os";
10
17
 
11
18
  import { runList } from "../../commands/list.mjs";
12
19
  import { CliError, EXIT_AUTH } from "../../lib/errors.mjs";
20
+ import { computeSkillShas } from "../../lib/crypto-shas.mjs";
21
+ import { globalLastSyncPath } from "../../lib/paths.mjs";
13
22
  import { createMockServer } from "../e2e/mock-server.mjs";
14
23
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
15
24
  import {
@@ -176,3 +185,502 @@ describe("runList — error paths", () => {
176
185
  );
177
186
  });
178
187
  });
188
+
189
+ // ── Drift state coverage (#1555) ────────────────────────────────────────
190
+ //
191
+ // These tests exercise the orchestration in list.mjs: the data sources
192
+ // (library, .last-sync, detected agents, on-disk placements) come
193
+ // together in `augmentSkill` to produce per-row `state` and the JSON
194
+ // `placements[]` array. The lower-level state-classification and disk-
195
+ // walk logic has its own exhaustive unit tests in drift.test.mjs and
196
+ // placement-walk.test.mjs; here we verify the integration.
197
+
198
+ const SKILL_MD_BODY = (name) =>
199
+ `---\nname: ${name}\ndescription: ${name} skill\n---\n\n# ${name}\n`;
200
+
201
+ /**
202
+ * Force Claude Code to be the detected agent by setting CLAUDECODE
203
+ * (one of its registered env signals). Without forcing detection,
204
+ * the sandbox has no agents and every state is `missing`.
205
+ */
206
+ function forceClaudeCodeDetected() {
207
+ process.env.CLAUDECODE = "1";
208
+ }
209
+
210
+ function unforceDetection() {
211
+ delete process.env.CLAUDECODE;
212
+ }
213
+
214
+ /**
215
+ * Seed a skill on disk at the claudeProject placement. Returns the
216
+ * SHAs computed from the seeded content — caller uses them when
217
+ * shaping the `.last-sync` baseline.
218
+ */
219
+ function seedClaudeSkillOnDisk(skillName, files) {
220
+ const dir = join(process.cwd(), ".claude", "skills", skillName);
221
+ mkdirSync(dir, { recursive: true });
222
+ for (const file of files) {
223
+ const filePath = join(dir, ...file.path.split("/"));
224
+ mkdirSync(join(filePath, ".."), { recursive: true });
225
+ writeFileSync(filePath, file.content, "utf8");
226
+ }
227
+ return computeSkillShas(files);
228
+ }
229
+
230
+ /** Write a v2 .last-sync at the sandbox home. */
231
+ function seedLastSync({ etag = '"v1"', syncedAt = "2025-01-01T00:00:00Z", skills = {} }) {
232
+ const stateDir = join(process.env.HOME, ".claude", "skillrepo");
233
+ mkdirSync(stateDir, { recursive: true });
234
+ writeFileSync(
235
+ globalLastSyncPath(),
236
+ JSON.stringify({ schemaVersion: 2, etag, syncedAt, skills }, null, 2),
237
+ "utf8",
238
+ );
239
+ }
240
+
241
+ describe("runList — drift state (#1555)", () => {
242
+ beforeEach(async () => {
243
+ await setup();
244
+ forceClaudeCodeDetected();
245
+ });
246
+ afterEach(async () => {
247
+ unforceDetection();
248
+ await teardown();
249
+ });
250
+
251
+ it("renders OK when SHA matches baseline and version matches library", async () => {
252
+ const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("clean") }];
253
+ const shas = seedClaudeSkillOnDisk("clean", files);
254
+ seedLastSync({
255
+ etag: '"v1"',
256
+ skills: {
257
+ "alice/clean": {
258
+ version: "1.0.0",
259
+ skillMdSha256: shas.skillMdSha256,
260
+ filesSha256: shas.filesSha256,
261
+ syncedAt: "x",
262
+ },
263
+ },
264
+ });
265
+ server.setEtag('"v1"');
266
+ server.setLibraryResponse({
267
+ skills: [makeSkill("alice", "clean", "1.0.0")],
268
+ removals: [],
269
+ syncedAt: "x",
270
+ });
271
+
272
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
273
+ const out = stdout.text();
274
+ assert.match(out, /clean/);
275
+ // Non-TTY capture stream → ASCII tokens.
276
+ assert.match(out, /\bOK\b/);
277
+ assert.match(out, /library in sync/);
278
+ });
279
+
280
+ it("renders STALE when library version is newer than synced version", async () => {
281
+ const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("aging") }];
282
+ const shas = seedClaudeSkillOnDisk("aging", files);
283
+ seedLastSync({
284
+ etag: '"v1"',
285
+ skills: {
286
+ "alice/aging": {
287
+ version: "1.0.0",
288
+ skillMdSha256: shas.skillMdSha256,
289
+ filesSha256: shas.filesSha256,
290
+ syncedAt: "x",
291
+ },
292
+ },
293
+ });
294
+ server.setEtag('"v2"');
295
+ server.setLibraryResponse({
296
+ skills: [makeSkill("alice", "aging", "1.1.0")],
297
+ removals: [],
298
+ syncedAt: "x",
299
+ });
300
+
301
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
302
+ const out = stdout.text();
303
+ assert.match(out, /\bSTALE\b/);
304
+ assert.match(out, /library has changed since last sync/);
305
+ });
306
+
307
+ it("renders EDIT when SHA differs from baseline but version matches", async () => {
308
+ // Seed the disk with one content, baseline with a DIFFERENT SHA —
309
+ // simulates the user hand-editing the SKILL.md after sync.
310
+ seedClaudeSkillOnDisk("touched", [
311
+ { path: "SKILL.md", content: SKILL_MD_BODY("touched-modified") },
312
+ ]);
313
+ // Baseline SHAs intentionally arbitrary; they won't match what's on
314
+ // disk because we used a different content string.
315
+ seedLastSync({
316
+ etag: '"v1"',
317
+ skills: {
318
+ "alice/touched": {
319
+ version: "1.0.0",
320
+ skillMdSha256: "f".repeat(64),
321
+ filesSha256: "e".repeat(64),
322
+ syncedAt: "x",
323
+ },
324
+ },
325
+ });
326
+ server.setEtag('"v1"');
327
+ server.setLibraryResponse({
328
+ skills: [makeSkill("alice", "touched", "1.0.0")],
329
+ removals: [],
330
+ syncedAt: "x",
331
+ });
332
+
333
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
334
+ const out = stdout.text();
335
+ assert.match(out, /\bEDIT\b/);
336
+ // ETag matches AND drift exists → footer must reflect that.
337
+ assert.match(out, /library in sync — but \d+ skill/);
338
+ });
339
+
340
+ it("renders MISS when skill is in library but no on-disk placement", async () => {
341
+ // No seedClaudeSkillOnDisk → no dir on disk.
342
+ seedLastSync({
343
+ etag: '"v1"',
344
+ skills: {
345
+ "alice/absent": {
346
+ version: "1.0.0",
347
+ skillMdSha256: "a".repeat(64),
348
+ filesSha256: "b".repeat(64),
349
+ syncedAt: "x",
350
+ },
351
+ },
352
+ });
353
+ server.setEtag('"v1"');
354
+ server.setLibraryResponse({
355
+ skills: [makeSkill("alice", "absent", "1.0.0")],
356
+ removals: [],
357
+ syncedAt: "x",
358
+ });
359
+
360
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
361
+ const out = stdout.text();
362
+ assert.match(out, /\bMISS\b/);
363
+ });
364
+
365
+ it("renders MISS when there is no .last-sync baseline (fresh install)", async () => {
366
+ // Seed on disk but NO baseline. Per the spec ("fresh install →
367
+ // missing everywhere") and computeSkillState's documented choice,
368
+ // we report missing rather than fabricating a `current` verdict.
369
+ seedClaudeSkillOnDisk("orphan", [
370
+ { path: "SKILL.md", content: SKILL_MD_BODY("orphan") },
371
+ ]);
372
+ server.setEtag('"v1"');
373
+ server.setLibraryResponse({
374
+ skills: [makeSkill("alice", "orphan", "1.0.0")],
375
+ removals: [],
376
+ syncedAt: "x",
377
+ });
378
+
379
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
380
+ const out = stdout.text();
381
+ assert.match(out, /\bMISS\b/);
382
+ assert.match(out, /No sync history on this machine/);
383
+ });
384
+
385
+ it("footer: ✓ library in sync when ETag matches and no drift", async () => {
386
+ const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("clean") }];
387
+ const shas = seedClaudeSkillOnDisk("clean", files);
388
+ seedLastSync({
389
+ etag: '"match"',
390
+ skills: {
391
+ "alice/clean": {
392
+ version: "1.0.0",
393
+ skillMdSha256: shas.skillMdSha256,
394
+ filesSha256: shas.filesSha256,
395
+ syncedAt: "x",
396
+ },
397
+ },
398
+ });
399
+ server.setEtag('"match"');
400
+ server.setLibraryResponse({
401
+ skills: [makeSkill("alice", "clean", "1.0.0")],
402
+ removals: [],
403
+ syncedAt: "x",
404
+ });
405
+
406
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
407
+ assert.match(stdout.text(), /library in sync — local skills up to date/);
408
+ });
409
+
410
+ it("footer: library has changed when ETag differs", async () => {
411
+ seedLastSync({ etag: '"old"', skills: {} });
412
+ server.setEtag('"new"');
413
+ server.setLibraryResponse({
414
+ skills: [makeSkill("alice", "any")],
415
+ removals: [],
416
+ syncedAt: "x",
417
+ });
418
+
419
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
420
+ assert.match(stdout.text(), /library has changed since last sync/);
421
+ });
422
+
423
+ it("--json includes state and placements per item; no library wrapper", async () => {
424
+ const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("scripted") }];
425
+ const shas = seedClaudeSkillOnDisk("scripted", files);
426
+ seedLastSync({
427
+ etag: '"v1"',
428
+ skills: {
429
+ "alice/scripted": {
430
+ version: "1.0.0",
431
+ skillMdSha256: shas.skillMdSha256,
432
+ filesSha256: shas.filesSha256,
433
+ syncedAt: "x",
434
+ },
435
+ },
436
+ });
437
+ server.setEtag('"v1"');
438
+ server.setLibraryResponse({
439
+ skills: [makeSkill("alice", "scripted", "1.0.0")],
440
+ removals: [],
441
+ syncedAt: "x",
442
+ });
443
+
444
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
445
+ const json = JSON.parse(stdout.text());
446
+ // Bare array preserved.
447
+ assert.ok(Array.isArray(json));
448
+ assert.equal(json.length, 1);
449
+ const item = json[0];
450
+ assert.equal(item.owner, "alice");
451
+ assert.equal(item.name, "scripted");
452
+ assert.equal(item.version, "1.0.0");
453
+ // New fields.
454
+ assert.equal(item.state, "current");
455
+ assert.ok(Array.isArray(item.placements));
456
+ assert.equal(item.placements.length, 1);
457
+ assert.deepEqual(item.placements[0], {
458
+ vendor: "claudeCode",
459
+ scope: "project",
460
+ state: "current",
461
+ localVersion: "1.0.0",
462
+ });
463
+ });
464
+
465
+ it("--json does NOT include any library-level footer fields", async () => {
466
+ // Verify the JSON contract is purely the existing bare-array shape
467
+ // with additive per-item fields — no top-level `library`, no
468
+ // `syncStatus`, no `summary`. Existing consumers stay unchanged.
469
+ server.setEtag('"v1"');
470
+ server.setLibraryResponse({
471
+ skills: [makeSkill("alice", "x")],
472
+ removals: [],
473
+ syncedAt: "x",
474
+ });
475
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
476
+ const parsed = JSON.parse(stdout.text());
477
+ assert.ok(Array.isArray(parsed));
478
+ // No object wrapper, no extra top-level keys.
479
+ assert.equal(parsed.constructor, Array);
480
+ });
481
+
482
+ it("renders worst-state-wins when multiple vendors are detected", async () => {
483
+ // claudeCode is detected via CLAUDECODE env var; force cursor too
484
+ // via its documented CURSOR_AGENT env signal. Seed the same skill
485
+ // in Claude's placement only — cursor will see it as MISSING and
486
+ // the rollup must therefore be MISSING (worst state wins).
487
+ process.env.CURSOR_AGENT = "1";
488
+ try {
489
+ const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("split") }];
490
+ const shas = seedClaudeSkillOnDisk("split", files);
491
+ seedLastSync({
492
+ etag: '"v1"',
493
+ skills: {
494
+ "alice/split": {
495
+ version: "1.0.0",
496
+ skillMdSha256: shas.skillMdSha256,
497
+ filesSha256: shas.filesSha256,
498
+ syncedAt: "x",
499
+ },
500
+ },
501
+ });
502
+ server.setEtag('"v1"');
503
+ server.setLibraryResponse({
504
+ skills: [makeSkill("alice", "split", "1.0.0")],
505
+ removals: [],
506
+ syncedAt: "x",
507
+ });
508
+
509
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
510
+ const [item] = JSON.parse(stdout.text());
511
+ // Rollup is MISSING because cursor's placement is missing.
512
+ assert.equal(item.state, "missing");
513
+ // Both vendors appear in placements with their own state.
514
+ const claude = item.placements.find((p) => p.vendor === "claudeCode");
515
+ const cursor = item.placements.find((p) => p.vendor === "cursor");
516
+ assert.ok(claude, "claudeCode placement must be present");
517
+ assert.ok(cursor, "cursor placement must be present");
518
+ assert.equal(claude.state, "current");
519
+ assert.equal(cursor.state, "missing");
520
+ } finally {
521
+ delete process.env.CURSOR_AGENT;
522
+ }
523
+ });
524
+
525
+ it("--json drift counter and human drift counter both reflect rollup", async () => {
526
+ // Two skills, one drifted, one clean. Verify the "1 needs
527
+ // attention" footer count + the JSON state field.
528
+ const cleanFiles = [{ path: "SKILL.md", content: SKILL_MD_BODY("clean") }];
529
+ const cleanShas = seedClaudeSkillOnDisk("clean", cleanFiles);
530
+ const driftFiles = [{ path: "SKILL.md", content: SKILL_MD_BODY("drift") }];
531
+ seedClaudeSkillOnDisk("drift", driftFiles);
532
+ seedLastSync({
533
+ etag: '"v1"',
534
+ skills: {
535
+ "alice/clean": {
536
+ version: "1.0.0",
537
+ skillMdSha256: cleanShas.skillMdSha256,
538
+ filesSha256: cleanShas.filesSha256,
539
+ syncedAt: "x",
540
+ },
541
+ "alice/drift": {
542
+ version: "1.0.0",
543
+ // Wrong SHA → drift detected as `edited`.
544
+ skillMdSha256: "0".repeat(64),
545
+ filesSha256: "0".repeat(64),
546
+ syncedAt: "x",
547
+ },
548
+ },
549
+ });
550
+ server.setEtag('"v1"');
551
+ server.setLibraryResponse({
552
+ skills: [
553
+ makeSkill("alice", "clean", "1.0.0"),
554
+ makeSkill("alice", "drift", "1.0.0"),
555
+ ],
556
+ removals: [],
557
+ syncedAt: "x",
558
+ });
559
+
560
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
561
+ const human = stdout.text();
562
+ assert.match(human, /2 skills in your library/);
563
+ assert.match(human, /1 needs attention/);
564
+ });
565
+
566
+ it("no detected agents → all rows MISS, friendly message at top + no-sync footer", async () => {
567
+ // Don't force any agent detection — sandbox should have none.
568
+ // Critically, do NOT call seedLastSync here: writing the state
569
+ // file creates `~/.claude/skillrepo/`, and the existence of
570
+ // `~/.claude` is one of claudeCode's documented detection
571
+ // signals (`{ type: "home", value: ".claude" }`). The test
572
+ // would then see Claude Code as detected. The "no agents"
573
+ // scenario means: no .claude/ in HOME, no .agents/ anywhere,
574
+ // no env-var hints. seedLastSync's side effects defeat that.
575
+ unforceDetection();
576
+ server.setEtag('"v1"');
577
+ server.setLibraryResponse({
578
+ skills: [makeSkill("alice", "lonely", "1.0.0")],
579
+ removals: [],
580
+ syncedAt: "x",
581
+ });
582
+
583
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
584
+ const out = stdout.text();
585
+ assert.match(out, /No agents detected in this project/);
586
+ assert.match(out, /\bMISS\b/);
587
+ // Footer assertion (Phase 4 L4): without `.last-sync`, the
588
+ // "no sync history" footer must fire — same code path as the
589
+ // fresh-install test, but here we lock in that BOTH messages
590
+ // appear together so a regression that suppresses one but not
591
+ // the other is caught.
592
+ assert.match(out, /No sync history on this machine/);
593
+ });
594
+
595
+ it("renders MISS when .last-sync exists but has no entry for this skill", async () => {
596
+ // Real-world case: a teammate added a new skill to the library
597
+ // between our last `update` and this `list`. The library
598
+ // response includes the new skill, our `.last-sync` does not.
599
+ // Per spec: "no baseline → missing." The user runs `update` to
600
+ // get the new skill.
601
+ //
602
+ // This locks in Phase 4 M2's gap: previous tests only covered
603
+ // the no-`.last-sync`-file case. The partial-map case shares
604
+ // the code path but exercises the per-skill lookup, not the
605
+ // file-existence check.
606
+ const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("known") }];
607
+ const shas = seedClaudeSkillOnDisk("known", files);
608
+ seedLastSync({
609
+ etag: '"v1"',
610
+ skills: {
611
+ // Only `known` is in the map. `new-skill` is not.
612
+ "alice/known": {
613
+ version: "1.0.0",
614
+ skillMdSha256: shas.skillMdSha256,
615
+ filesSha256: shas.filesSha256,
616
+ syncedAt: "x",
617
+ },
618
+ },
619
+ });
620
+ server.setEtag('"v1"');
621
+ server.setLibraryResponse({
622
+ skills: [
623
+ makeSkill("alice", "known", "1.0.0"),
624
+ makeSkill("alice", "new-skill", "0.1.0"),
625
+ ],
626
+ removals: [],
627
+ syncedAt: "x",
628
+ });
629
+
630
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
631
+ const json = JSON.parse(stdout.text());
632
+ const known = json.find((s) => s.name === "known");
633
+ const newer = json.find((s) => s.name === "new-skill");
634
+ assert.equal(known.state, "current");
635
+ // `new-skill` has no baseline in the map → MISSING per the
636
+ // computeSkillState contract, even though it's a brand-new
637
+ // skill that's never been on disk.
638
+ assert.equal(newer.state, "missing");
639
+ });
640
+
641
+ it("renders MISS when .last-sync entry has wrong-typed fields (validateBaselineShape guard)", async () => {
642
+ // PR #1566 removed the lookupSkill / getLastSyncSkillEntry
643
+ // helpers and replaced the per-entry shape guard with
644
+ // validateBaselineShape inside list.mjs. The removed helper tests
645
+ // covered partial / wrong-type entries; nothing replaced them
646
+ // at the list layer. This test re-establishes that property:
647
+ // a `.last-sync` file with a syntactically valid `skills` map
648
+ // but per-entry fields of the wrong type must NOT crash
649
+ // computeSkillState (e.g., via semver.gt receiving a number),
650
+ // and must render the affected skill as MISSING — the
651
+ // conservative verdict for cache state we cannot trust.
652
+ seedClaudeSkillOnDisk("weird", [
653
+ { path: "SKILL.md", content: SKILL_MD_BODY("weird") },
654
+ ]);
655
+ seedLastSync({
656
+ etag: '"v1"',
657
+ skills: {
658
+ // Per-entry shape is malformed: version is a number, SHAs
659
+ // are wrong types. readLastSync's top-level coercion does
660
+ // NOT touch per-entry shapes — that's validateBaselineShape's
661
+ // job.
662
+ "alice/weird": {
663
+ version: 42,
664
+ skillMdSha256: true,
665
+ filesSha256: null,
666
+ syncedAt: "x",
667
+ },
668
+ },
669
+ });
670
+ server.setEtag('"v1"');
671
+ server.setLibraryResponse({
672
+ skills: [makeSkill("alice", "weird", "1.0.0")],
673
+ removals: [],
674
+ syncedAt: "x",
675
+ });
676
+
677
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
678
+ const [item] = JSON.parse(stdout.text());
679
+ assert.equal(item.state, "missing");
680
+ // Sanity: no crash, output parses cleanly, the per-vendor
681
+ // placement also reports missing.
682
+ assert.equal(item.placements[0].state, "missing");
683
+ // `localVersion` should be null since the baseline was rejected.
684
+ assert.equal(item.placements[0].localVersion, null);
685
+ });
686
+ });
@@ -206,6 +206,39 @@ describe("writeConfig", () => {
206
206
  assert.equal(mode, 0o600, `Expected 0o600, got ${mode.toString(8)}`);
207
207
  });
208
208
 
209
+ // Telemetry opt-in/out flag persistence (#1539). The field is only
210
+ // written when the caller explicitly sets it (boolean) — omitting
211
+ // means "no preference recorded" and the telemetry module treats
212
+ // that as the opt-out-friendly default of enabled.
213
+ it("persists telemetry=true when explicitly set", () => {
214
+ writeConfig({
215
+ apiKey: "sk_live_abc",
216
+ serverUrl: "https://example.com",
217
+ telemetry: true,
218
+ });
219
+ const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
220
+ assert.equal(raw.telemetry, true);
221
+ });
222
+
223
+ it("persists telemetry=false when explicitly set", () => {
224
+ writeConfig({
225
+ apiKey: "sk_live_abc",
226
+ serverUrl: "https://example.com",
227
+ telemetry: false,
228
+ });
229
+ const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
230
+ assert.equal(raw.telemetry, false);
231
+ });
232
+
233
+ it("does NOT write a telemetry field when omitted (preserves no-preference state)", () => {
234
+ writeConfig({
235
+ apiKey: "sk_live_abc",
236
+ serverUrl: "https://example.com",
237
+ });
238
+ const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
239
+ assert.equal("telemetry" in raw, false);
240
+ });
241
+
209
242
  it("throws validationError on missing apiKey", () => {
210
243
  assert.throws(
211
244
  () => writeConfig({ serverUrl: "https://example.com" }),