@kntic/kntic 0.4.7 → 0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kntic/kntic",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "author": "Thomas Robak <contact@kntic.ai> (https://kntic.ai)",
5
5
  "description": "KNTIC CLI — bootstrap and manage KNTIC projects",
6
6
  "main": "src/index.js",
package/src/cli.js CHANGED
@@ -30,7 +30,10 @@ if (!subcommand || subcommand === "usage") {
30
30
  process.exit(1);
31
31
  }
32
32
  } else if (subcommand === "update") {
33
- const updateOpts = { libOnly: args.includes("--lib-only") };
33
+ const updateOpts = {
34
+ libOnly: args.includes("--lib-only"),
35
+ compose: args.includes("--compose"),
36
+ };
34
37
  commands.update(updateOpts).catch((err) => {
35
38
  console.error(`Error: ${err.message}`);
36
39
  process.exit(1);
@@ -189,6 +189,8 @@ function extractLibOnly(tarball, destDir) {
189
189
  * - .kntic/lib and .kntic/adrs are **replaced** (cleared first, then extracted).
190
190
  * - .kntic/hooks/gia/internal is **updated** (existing files are overwritten or new files
191
191
  * added, but files not present in the archive are preserved).
192
+ * - .kntic/hooks/gia/specific is **extracted only if the directory does not already exist**
193
+ * (one-time bootstrap; user customizations are never overwritten).
192
194
  * - .kntic/gia/weights.json is **replaced** if present in the archive.
193
195
  *
194
196
  * @param {string} tarball – path to the .tar.gz file
@@ -198,6 +200,7 @@ function extractUpdate(tarball, destDir) {
198
200
  const libDir = path.join(destDir, ".kntic", "lib");
199
201
  const adrsDir = path.join(destDir, ".kntic", "adrs");
200
202
  const giaInternalDir = path.join(destDir, ".kntic", "hooks", "gia", "internal");
203
+ const giaSpecificDir = path.join(destDir, ".kntic", "hooks", "gia", "specific");
201
204
  const giaDir = path.join(destDir, ".kntic", "gia");
202
205
 
203
206
  // Ensure target directories exist
@@ -211,6 +214,7 @@ function extractUpdate(tarball, destDir) {
211
214
  clearDirectory(adrsDir);
212
215
 
213
216
  // Note: gia/internal hooks are NOT cleared — update semantics (overwrite/add, preserve others)
217
+ // Note: gia/specific hooks are only extracted if the directory does NOT already exist
214
218
  // Note: gia is NOT cleared — only weights.json is replaced
215
219
 
216
220
  // Extract .kntic/lib/ and .kntic/adrs/ (guaranteed to be in the archive)
@@ -229,6 +233,20 @@ function extractUpdate(tarball, destDir) {
229
233
  // No .kntic/hooks/gia/internal/ in the archive — that's fine
230
234
  }
231
235
 
236
+ // Extract .kntic/hooks/gia/specific/ only if it does not already exist on disk.
237
+ // This is a one-time bootstrap — once the user has specific hooks, they are never
238
+ // overwritten by update.
239
+ if (!fs.existsSync(giaSpecificDir)) {
240
+ try {
241
+ execSync(
242
+ `tar xzf "${tarball}" -C "${destDir}" "./.kntic/hooks/gia/specific/"`,
243
+ { stdio: "pipe" }
244
+ );
245
+ } catch (_) {
246
+ // No .kntic/hooks/gia/specific/ in the archive — that's fine
247
+ }
248
+ }
249
+
232
250
  // Extract .kntic/gia/weights.json separately — archive may not contain it
233
251
  try {
234
252
  execSync(
@@ -240,6 +258,153 @@ function extractUpdate(tarball, destDir) {
240
258
  }
241
259
  }
242
260
 
261
+ /**
262
+ * Parse an env file into an ordered list of entries.
263
+ * Each entry is either:
264
+ * { type: "comment", lines: ["# ...", "# ..."] }
265
+ * { type: "variable", key: "KEY", line: "KEY=VALUE", commentLines: [] }
266
+ * { type: "blank", lines: [""] }
267
+ *
268
+ * Comment lines immediately preceding a variable are attached to that variable.
269
+ */
270
+ function parseEnvEntries(content) {
271
+ const rawLines = content.split("\n");
272
+ // Remove trailing empty string from final newline
273
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
274
+ rawLines.pop();
275
+ }
276
+
277
+ const entries = [];
278
+ let pendingComments = [];
279
+
280
+ for (const line of rawLines) {
281
+ if (line === "" || (line.trim() === "" && !line.startsWith("#"))) {
282
+ // Blank line — if we're accumulating comments, treat blanks between
283
+ // comments as part of the comment block
284
+ pendingComments.push(line);
285
+ } else if (line.startsWith("#")) {
286
+ pendingComments.push(line);
287
+ } else {
288
+ // Variable line — KEY=VALUE (possibly with trailing comment)
289
+ const eqIdx = line.indexOf("=");
290
+ if (eqIdx !== -1) {
291
+ const key = line.substring(0, eqIdx).trim();
292
+ entries.push({
293
+ type: "variable",
294
+ key,
295
+ line,
296
+ commentLines: pendingComments.length > 0 ? [...pendingComments] : [],
297
+ });
298
+ pendingComments = [];
299
+ } else {
300
+ // Unknown line — treat as comment
301
+ pendingComments.push(line);
302
+ }
303
+ }
304
+ }
305
+
306
+ // Any trailing comments/blanks that didn't precede a variable
307
+ if (pendingComments.length > 0) {
308
+ entries.push({ type: "comment", lines: pendingComments });
309
+ }
310
+
311
+ return entries;
312
+ }
313
+
314
+ /**
315
+ * Extract the set of variable keys from parsed entries.
316
+ */
317
+ function extractKeys(entries) {
318
+ const keys = new Set();
319
+ for (const entry of entries) {
320
+ if (entry.type === "variable") {
321
+ keys.add(entry.key);
322
+ }
323
+ }
324
+ return keys;
325
+ }
326
+
327
+ /**
328
+ * Merge new environment variables from a template .kntic.env into the user's .kntic.env.
329
+ *
330
+ * - Variables that exist in the user's file are never overwritten.
331
+ * - Variables in the template that are missing from the user's file are appended,
332
+ * along with any preceding comment lines from the template.
333
+ *
334
+ * @param {string} templatePath – path to the template .kntic.env (from archive)
335
+ * @param {string} userEnvPath – path to the user's .kntic.env
336
+ * @returns {string[]} list of newly added variable keys
337
+ */
338
+ function mergeEnvFile(templatePath, userEnvPath) {
339
+ const templateContent = fs.readFileSync(templatePath, "utf8");
340
+ const templateEntries = parseEnvEntries(templateContent);
341
+
342
+ let userContent = "";
343
+ if (fs.existsSync(userEnvPath)) {
344
+ userContent = fs.readFileSync(userEnvPath, "utf8");
345
+ }
346
+
347
+ const userKeys = extractKeys(parseEnvEntries(userContent));
348
+ const newVars = [];
349
+
350
+ // Build the block to append
351
+ let appendBlock = "";
352
+
353
+ for (const entry of templateEntries) {
354
+ if (entry.type !== "variable") continue;
355
+ if (userKeys.has(entry.key)) continue;
356
+
357
+ // This is a new variable — append its comment lines + variable line
358
+ newVars.push(entry.key);
359
+
360
+ if (entry.commentLines.length > 0) {
361
+ appendBlock += entry.commentLines.join("\n") + "\n";
362
+ }
363
+ appendBlock += entry.line + "\n";
364
+ }
365
+
366
+ if (newVars.length === 0) {
367
+ console.log(".kntic.env is up to date.");
368
+ return newVars;
369
+ }
370
+
371
+ // Ensure user file ends with a newline before appending
372
+ let separator = "";
373
+ if (userContent.length > 0 && !userContent.endsWith("\n")) {
374
+ separator = "\n";
375
+ }
376
+ // Add blank line separator if user file doesn't end with a blank line
377
+ if (userContent.length > 0 && !userContent.endsWith("\n\n") && !(userContent.endsWith("\n") && userContent.length === 0)) {
378
+ separator += "\n";
379
+ }
380
+
381
+ fs.writeFileSync(userEnvPath, userContent + separator + appendBlock);
382
+
383
+ console.log(`Added new environment variables to .kntic.env: ${newVars.join(", ")}`);
384
+ return newVars;
385
+ }
386
+
387
+ /**
388
+ * Extract kntic.yml from the bootstrap archive, backing up any existing copy.
389
+ *
390
+ * @param {string} tarball – path to the .tar.gz file
391
+ * @param {string} destDir – target directory (usually ".")
392
+ */
393
+ function extractCompose(tarball, destDir) {
394
+ const composePath = path.join(destDir, "kntic.yml");
395
+ // Backup existing
396
+ if (fs.existsSync(composePath)) {
397
+ fs.copyFileSync(composePath, composePath + ".bak");
398
+ console.log("Backing up kntic.yml → kntic.yml.bak");
399
+ }
400
+ // Extract from archive
401
+ execSync(
402
+ `tar xzf "${tarball}" -C "${destDir}" "./kntic.yml"`,
403
+ { stdio: "pipe" }
404
+ );
405
+ console.log("Updated kntic.yml from bootstrap template.");
406
+ }
407
+
243
408
  async function update(options = {}) {
244
409
  const libOnly = options.libOnly || false;
245
410
 
@@ -256,10 +421,42 @@ async function update(options = {}) {
256
421
  console.log("Updating .kntic/lib …");
257
422
  extractLibOnly(tmpFile, ".");
258
423
  } else {
259
- console.log("Updating .kntic/lib, .kntic/adrs, .kntic/hooks/gia/internal, and .kntic/gia/weights.json …");
424
+ console.log("Updating .kntic/lib, .kntic/adrs, .kntic/hooks/gia/internal, .kntic/hooks/gia/specific (if new), and .kntic/gia/weights.json …");
260
425
  extractUpdate(tmpFile, ".");
261
426
  }
262
427
 
428
+ // Merge new env variables from the archive's .kntic.env template
429
+ const timestamp = Date.now();
430
+ const tmpEnvTemplate = path.join(os.tmpdir(), `kntic-env-template-${timestamp}`);
431
+ try {
432
+ const tmpExtractDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-env-extract-"));
433
+ try {
434
+ execSync(`tar xzf "${tmpFile}" -C "${tmpExtractDir}" "./.kntic.env"`, { stdio: "pipe" });
435
+ const extractedEnv = path.join(tmpExtractDir, ".kntic.env");
436
+ if (fs.existsSync(extractedEnv)) {
437
+ fs.copyFileSync(extractedEnv, tmpEnvTemplate);
438
+ mergeEnvFile(tmpEnvTemplate, ".kntic.env");
439
+ } else {
440
+ console.log("No .kntic.env template in archive, skipping env merge.");
441
+ }
442
+ } catch (_) {
443
+ console.log("No .kntic.env template in archive, skipping env merge.");
444
+ } finally {
445
+ fs.rmSync(tmpExtractDir, { recursive: true, force: true });
446
+ }
447
+ } finally {
448
+ try { fs.unlinkSync(tmpEnvTemplate); } catch (_) {}
449
+ }
450
+
451
+ // Replace kntic.yml from archive if --compose flag is set
452
+ if (options.compose) {
453
+ try {
454
+ extractCompose(tmpFile, ".");
455
+ } catch (_) {
456
+ console.log("No kntic.yml in archive, skipping compose update.");
457
+ }
458
+ }
459
+
263
460
  // Update KNTIC_VERSION in .kntic.env
264
461
  updateEnvVersion(version);
265
462
 
@@ -270,13 +467,15 @@ async function update(options = {}) {
270
467
  if (libOnly) {
271
468
  console.log("Done. .kntic/lib updated successfully.");
272
469
  } else {
273
- console.log("Done. .kntic/lib, .kntic/adrs, .kntic/hooks/gia/internal, and .kntic/gia/weights.json updated successfully.");
470
+ console.log("Done. .kntic/lib, .kntic/adrs, .kntic/hooks/gia/internal, .kntic/hooks/gia/specific (if new), and .kntic/gia/weights.json updated successfully.");
274
471
  }
275
472
  }
276
473
 
277
474
  module.exports = update;
278
475
  module.exports.extractLibOnly = extractLibOnly;
279
476
  module.exports.extractUpdate = extractUpdate;
477
+ module.exports.extractCompose = extractCompose;
280
478
  module.exports.extractVersion = extractVersion;
281
479
  module.exports.clearDirectory = clearDirectory;
282
480
  module.exports.updateEnvVersion = updateEnvVersion;
481
+ module.exports.mergeEnvFile = mergeEnvFile;
@@ -7,7 +7,7 @@ const path = require("path");
7
7
  const os = require("os");
8
8
  const { execSync } = require("child_process");
9
9
 
10
- const { extractLibOnly, extractUpdate, extractVersion, clearDirectory, updateEnvVersion } = require("./update");
10
+ const { extractLibOnly, extractUpdate, extractCompose, extractVersion, clearDirectory, updateEnvVersion, mergeEnvFile } = require("./update");
11
11
 
12
12
  /**
13
13
  * Helper — create a tar.gz archive in `tmpDir` containing the given files.
@@ -408,6 +408,75 @@ describe("extractUpdate", () => {
408
408
  );
409
409
  });
410
410
 
411
+ it("extracts .kntic/hooks/gia/specific when the directory does not exist", () => {
412
+ const tarball = createTarball(tmpDir, {
413
+ ".kntic/lib/orchestrator.py": "# orchestrator\n",
414
+ ".kntic/adrs/ADR-001.md": "# ADR 001\n",
415
+ ".kntic/hooks/gia/specific/my-hook.sh": "#!/bin/sh\necho specific\n",
416
+ ".kntic/hooks/gia/specific/another.sh": "#!/bin/sh\necho another\n",
417
+ });
418
+
419
+ // specific dir does not exist yet
420
+ const specificDir = path.join(destDir, ".kntic", "hooks", "gia", "specific");
421
+ assert.ok(!fs.existsSync(specificDir), "specific dir must not exist before extraction");
422
+
423
+ extractUpdate(tarball, destDir);
424
+
425
+ // specific hooks must be extracted
426
+ assert.equal(
427
+ fs.readFileSync(path.join(specificDir, "my-hook.sh"), "utf8"),
428
+ "#!/bin/sh\necho specific\n"
429
+ );
430
+ assert.equal(
431
+ fs.readFileSync(path.join(specificDir, "another.sh"), "utf8"),
432
+ "#!/bin/sh\necho another\n"
433
+ );
434
+ });
435
+
436
+ it("does NOT overwrite .kntic/hooks/gia/specific when the directory already exists", () => {
437
+ const specificDir = path.join(destDir, ".kntic", "hooks", "gia", "specific");
438
+ fs.mkdirSync(specificDir, { recursive: true });
439
+ fs.writeFileSync(path.join(specificDir, "custom.sh"), "#!/bin/sh\necho custom\n");
440
+
441
+ const tarball = createTarball(tmpDir, {
442
+ ".kntic/lib/orchestrator.py": "# orchestrator\n",
443
+ ".kntic/adrs/ADR-001.md": "# ADR 001\n",
444
+ ".kntic/hooks/gia/specific/my-hook.sh": "#!/bin/sh\necho from-archive\n",
445
+ });
446
+
447
+ extractUpdate(tarball, destDir);
448
+
449
+ // User's custom hook must be preserved
450
+ assert.equal(
451
+ fs.readFileSync(path.join(specificDir, "custom.sh"), "utf8"),
452
+ "#!/bin/sh\necho custom\n",
453
+ "user's custom specific hook must be preserved"
454
+ );
455
+
456
+ // Archive's specific hook must NOT be extracted
457
+ assert.ok(
458
+ !fs.existsSync(path.join(specificDir, "my-hook.sh")),
459
+ "archive specific hook must not be extracted when directory already exists"
460
+ );
461
+ });
462
+
463
+ it("works when archive has no .kntic/hooks/gia/specific and directory does not exist", () => {
464
+ const tarball = createTarball(tmpDir, {
465
+ ".kntic/lib/orchestrator.py": "# orchestrator\n",
466
+ ".kntic/adrs/ADR-001.md": "# ADR 001\n",
467
+ });
468
+
469
+ // Should not throw
470
+ extractUpdate(tarball, destDir);
471
+
472
+ // specific dir should not be created
473
+ const specificDir = path.join(destDir, ".kntic", "hooks", "gia", "specific");
474
+ assert.ok(
475
+ !fs.existsSync(specificDir),
476
+ "specific dir must not be created when not in archive"
477
+ );
478
+ });
479
+
411
480
  it("preserves files outside .kntic/lib, .kntic/adrs, and .kntic/hooks/gia/internal", () => {
412
481
  const knticDir = path.join(destDir, ".kntic");
413
482
  fs.mkdirSync(knticDir, { recursive: true });
@@ -504,3 +573,274 @@ describe("extractVersion (update module)", () => {
504
573
  assert.throws(() => extractVersion("no-version-here.tar.gz"));
505
574
  });
506
575
  });
576
+
577
+ describe("mergeEnvFile", () => {
578
+ let tmpDir;
579
+
580
+ beforeEach(() => {
581
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-test-merge-"));
582
+ });
583
+
584
+ afterEach(() => {
585
+ fs.rmSync(tmpDir, { recursive: true, force: true });
586
+ });
587
+
588
+ it("appends new keys and their preceding comments from the template", () => {
589
+ const templatePath = path.join(tmpDir, "template.env");
590
+ const userPath = path.join(tmpDir, "user.env");
591
+
592
+ fs.writeFileSync(templatePath, [
593
+ "# Existing var",
594
+ "UID=1000",
595
+ "",
596
+ "# Project prefix used for naming",
597
+ "KNTIC_PRJ_PREFIX=myproject",
598
+ "",
599
+ "# UI port for the dashboard",
600
+ "KNTIC_UI_PORT=8080",
601
+ "",
602
+ ].join("\n"));
603
+
604
+ fs.writeFileSync(userPath, "UID=5000\n");
605
+
606
+ const added = mergeEnvFile(templatePath, userPath);
607
+
608
+ assert.deepEqual(added, ["KNTIC_PRJ_PREFIX", "KNTIC_UI_PORT"]);
609
+
610
+ const content = fs.readFileSync(userPath, "utf8");
611
+ assert.ok(content.includes("UID=5000"), "existing var must be preserved");
612
+ assert.ok(content.includes("# Project prefix used for naming"), "comment for new var must be included");
613
+ assert.ok(content.includes("KNTIC_PRJ_PREFIX=myproject"), "new var must be appended");
614
+ assert.ok(content.includes("# UI port for the dashboard"), "comment for second new var must be included");
615
+ assert.ok(content.includes("KNTIC_UI_PORT=8080"), "second new var must be appended");
616
+ });
617
+
618
+ it("preserves user keys not in the template", () => {
619
+ const templatePath = path.join(tmpDir, "template.env");
620
+ const userPath = path.join(tmpDir, "user.env");
621
+
622
+ fs.writeFileSync(templatePath, "UID=1000\n");
623
+ fs.writeFileSync(userPath, "UID=5000\nMY_CUSTOM_VAR=hello\n");
624
+
625
+ const added = mergeEnvFile(templatePath, userPath);
626
+
627
+ assert.deepEqual(added, []);
628
+ const content = fs.readFileSync(userPath, "utf8");
629
+ assert.ok(content.includes("MY_CUSTOM_VAR=hello"), "user's extra key must be preserved");
630
+ assert.ok(content.includes("UID=5000"), "existing var must be preserved");
631
+ });
632
+
633
+ it("does not overwrite overlapping keys — user's value wins", () => {
634
+ const templatePath = path.join(tmpDir, "template.env");
635
+ const userPath = path.join(tmpDir, "user.env");
636
+
637
+ fs.writeFileSync(templatePath, "UID=1000\nGID=1000\n");
638
+ fs.writeFileSync(userPath, "UID=5000\nGID=5000\n");
639
+
640
+ const added = mergeEnvFile(templatePath, userPath);
641
+
642
+ assert.deepEqual(added, []);
643
+ const content = fs.readFileSync(userPath, "utf8");
644
+ assert.ok(content.includes("UID=5000"), "user's UID must be preserved");
645
+ assert.ok(content.includes("GID=5000"), "user's GID must be preserved");
646
+ assert.ok(!content.includes("UID=1000"), "template UID must not appear");
647
+ assert.ok(!content.includes("GID=1000"), "template GID must not appear");
648
+ });
649
+
650
+ it("writes all template entries when user file does not exist", () => {
651
+ const templatePath = path.join(tmpDir, "template.env");
652
+ const userPath = path.join(tmpDir, "user.env");
653
+
654
+ fs.writeFileSync(templatePath, [
655
+ "# User ID",
656
+ "UID=1000",
657
+ "# Group ID",
658
+ "GID=1000",
659
+ "",
660
+ ].join("\n"));
661
+
662
+ // user file does not exist
663
+ assert.ok(!fs.existsSync(userPath));
664
+
665
+ const added = mergeEnvFile(templatePath, userPath);
666
+
667
+ assert.deepEqual(added, ["UID", "GID"]);
668
+ const content = fs.readFileSync(userPath, "utf8");
669
+ assert.ok(content.includes("# User ID"), "comment must be included");
670
+ assert.ok(content.includes("UID=1000"), "UID must be written");
671
+ assert.ok(content.includes("# Group ID"), "comment must be included");
672
+ assert.ok(content.includes("GID=1000"), "GID must be written");
673
+ });
674
+
675
+ it("includes comment lines that immediately precede a new key", () => {
676
+ const templatePath = path.join(tmpDir, "template.env");
677
+ const userPath = path.join(tmpDir, "user.env");
678
+
679
+ fs.writeFileSync(templatePath, [
680
+ "UID=1000",
681
+ "",
682
+ "# Branch strategy for the project",
683
+ "# Allowed values: trunk, gitflow",
684
+ "KNTIC_BRANCH_STRATEGY=trunk",
685
+ "",
686
+ ].join("\n"));
687
+
688
+ fs.writeFileSync(userPath, "UID=5000\n");
689
+
690
+ const added = mergeEnvFile(templatePath, userPath);
691
+
692
+ assert.deepEqual(added, ["KNTIC_BRANCH_STRATEGY"]);
693
+ const content = fs.readFileSync(userPath, "utf8");
694
+ assert.ok(content.includes("# Branch strategy for the project"), "first comment line must be included");
695
+ assert.ok(content.includes("# Allowed values: trunk, gitflow"), "second comment line must be included");
696
+ assert.ok(content.includes("KNTIC_BRANCH_STRATEGY=trunk"), "new var must be appended");
697
+ });
698
+
699
+ it("leaves file unchanged when there are no new keys", () => {
700
+ const templatePath = path.join(tmpDir, "template.env");
701
+ const userPath = path.join(tmpDir, "user.env");
702
+
703
+ fs.writeFileSync(templatePath, "UID=1000\nGID=1000\n");
704
+ fs.writeFileSync(userPath, "UID=5000\nGID=5000\n");
705
+
706
+ const originalContent = fs.readFileSync(userPath, "utf8");
707
+ const added = mergeEnvFile(templatePath, userPath);
708
+
709
+ assert.deepEqual(added, []);
710
+ const content = fs.readFileSync(userPath, "utf8");
711
+ assert.equal(content, originalContent, "file must not be modified");
712
+ });
713
+
714
+ it("writes all template entries when user file is empty", () => {
715
+ const templatePath = path.join(tmpDir, "template.env");
716
+ const userPath = path.join(tmpDir, "user.env");
717
+
718
+ fs.writeFileSync(templatePath, "# Port\nPORT=3000\n");
719
+ fs.writeFileSync(userPath, "");
720
+
721
+ const added = mergeEnvFile(templatePath, userPath);
722
+
723
+ assert.deepEqual(added, ["PORT"]);
724
+ const content = fs.readFileSync(userPath, "utf8");
725
+ assert.ok(content.includes("# Port"), "comment must be included");
726
+ assert.ok(content.includes("PORT=3000"), "variable must be written");
727
+ });
728
+ });
729
+
730
+ describe("extractCompose", () => {
731
+ let tmpDir;
732
+ let destDir;
733
+
734
+ beforeEach(() => {
735
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-test-compose-"));
736
+ destDir = path.join(tmpDir, "dest");
737
+ fs.mkdirSync(destDir, { recursive: true });
738
+ });
739
+
740
+ afterEach(() => {
741
+ fs.rmSync(tmpDir, { recursive: true, force: true });
742
+ });
743
+
744
+ it("extracts kntic.yml from archive", () => {
745
+ const composeContent = "services:\n dashboard:\n image: kntic/dashboard:latest\n";
746
+ const tarball = createTarball(tmpDir, {
747
+ "kntic.yml": composeContent,
748
+ ".kntic/lib/orchestrator.py": "# orchestrator\n",
749
+ });
750
+
751
+ extractCompose(tarball, destDir);
752
+
753
+ assert.equal(
754
+ fs.readFileSync(path.join(destDir, "kntic.yml"), "utf8"),
755
+ composeContent,
756
+ "kntic.yml content must match archive"
757
+ );
758
+ });
759
+
760
+ it("creates .bak backup of existing kntic.yml", () => {
761
+ const oldContent = "services:\n dashboard:\n image: old:v1\n";
762
+ const newContent = "services:\n dashboard:\n image: new:v2\n";
763
+ fs.writeFileSync(path.join(destDir, "kntic.yml"), oldContent);
764
+
765
+ const tarball = createTarball(tmpDir, {
766
+ "kntic.yml": newContent,
767
+ });
768
+
769
+ extractCompose(tarball, destDir);
770
+
771
+ // Backup must contain old content
772
+ assert.equal(
773
+ fs.readFileSync(path.join(destDir, "kntic.yml.bak"), "utf8"),
774
+ oldContent,
775
+ "backup must contain previous content"
776
+ );
777
+ // New file must contain archive content
778
+ assert.equal(
779
+ fs.readFileSync(path.join(destDir, "kntic.yml"), "utf8"),
780
+ newContent,
781
+ "kntic.yml must be replaced with archive content"
782
+ );
783
+ });
784
+
785
+ it("overwrites existing .bak file", () => {
786
+ const veryOldContent = "services:\n old: true\n";
787
+ const oldContent = "services:\n current: true\n";
788
+ const newContent = "services:\n new: true\n";
789
+
790
+ fs.writeFileSync(path.join(destDir, "kntic.yml.bak"), veryOldContent);
791
+ fs.writeFileSync(path.join(destDir, "kntic.yml"), oldContent);
792
+
793
+ const tarball = createTarball(tmpDir, {
794
+ "kntic.yml": newContent,
795
+ });
796
+
797
+ extractCompose(tarball, destDir);
798
+
799
+ // .bak must now have the oldContent (not veryOldContent)
800
+ assert.equal(
801
+ fs.readFileSync(path.join(destDir, "kntic.yml.bak"), "utf8"),
802
+ oldContent,
803
+ "backup must be overwritten with latest previous content"
804
+ );
805
+ assert.equal(
806
+ fs.readFileSync(path.join(destDir, "kntic.yml"), "utf8"),
807
+ newContent,
808
+ "kntic.yml must contain archive content"
809
+ );
810
+ });
811
+
812
+ it("works when no existing kntic.yml on disk", () => {
813
+ const newContent = "services:\n dashboard:\n image: kntic/dashboard:latest\n";
814
+ const tarball = createTarball(tmpDir, {
815
+ "kntic.yml": newContent,
816
+ });
817
+
818
+ // No kntic.yml exists
819
+ assert.ok(!fs.existsSync(path.join(destDir, "kntic.yml")));
820
+
821
+ extractCompose(tarball, destDir);
822
+
823
+ // File must be extracted
824
+ assert.equal(
825
+ fs.readFileSync(path.join(destDir, "kntic.yml"), "utf8"),
826
+ newContent,
827
+ "kntic.yml must be extracted from archive"
828
+ );
829
+ // No backup should exist
830
+ assert.ok(
831
+ !fs.existsSync(path.join(destDir, "kntic.yml.bak")),
832
+ "no backup must be created when original did not exist"
833
+ );
834
+ });
835
+
836
+ it("throws when archive has no kntic.yml", () => {
837
+ const tarball = createTarball(tmpDir, {
838
+ ".kntic/lib/orchestrator.py": "# orchestrator\n",
839
+ });
840
+
841
+ assert.throws(
842
+ () => extractCompose(tarball, destDir),
843
+ "must throw when archive does not contain kntic.yml"
844
+ );
845
+ });
846
+ });
@@ -9,8 +9,9 @@ function usage() {
9
9
  console.log(" start Build and start KNTIC services via docker compose (uses kntic.yml + .kntic.env)");
10
10
  console.log(" --screen Run inside a GNU screen session");
11
11
  console.log(" stop Stop KNTIC services via docker compose");
12
- console.log(" update Download the latest KNTIC bootstrap and update .kntic/lib, .kntic/adrs, .kntic/hooks/gia/internal, and .kntic/gia/weights.json");
13
- console.log(" --lib-only Update only .kntic/lib (skip .kntic/adrs, .kntic/hooks/gia/internal, and .kntic/gia/weights.json)");
12
+ console.log(" update Download the latest KNTIC bootstrap and update .kntic/lib, .kntic/adrs, .kntic/hooks/gia/internal, .kntic/hooks/gia/specific (if new), and .kntic/gia/weights.json");
13
+ console.log(" --lib-only Update only .kntic/lib (skip adrs, hooks, and weights)");
14
+ console.log(" --compose Also replace kntic.yml from the bootstrap template (backs up to kntic.yml.bak)");
14
15
  console.log("");
15
16
  }
16
17