@kntic/kntic 0.10.0 → 0.11.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.10.0",
3
+ "version": "0.11.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
@@ -17,7 +17,10 @@ if (!subcommand || subcommand === "usage") {
17
17
  });
18
18
  } else if (subcommand === "start") {
19
19
  try {
20
- const startOpts = { screen: args.includes("--screen") };
20
+ const startOpts = {
21
+ screen: args.includes("--screen"),
22
+ tmux: args.includes("--tmux"),
23
+ };
21
24
  commands.start(startOpts);
22
25
  } catch (err) {
23
26
  console.error(`Error: ${err.message}`);
@@ -14,11 +14,24 @@ function isScreenAvailable() {
14
14
  }
15
15
  }
16
16
 
17
+ function isTmuxAvailable() {
18
+ try {
19
+ execSync("which tmux", { stdio: "ignore" });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
17
26
  function isInsideScreen() {
18
27
  return !!process.env.STY;
19
28
  }
20
29
 
21
- function getScreenName() {
30
+ function isInsideTmux() {
31
+ return !!process.env.TMUX;
32
+ }
33
+
34
+ function getSessionName() {
22
35
  try {
23
36
  const content = readFileSync(".kntic.env", "utf8");
24
37
  const match = content.match(/^KNTIC_PRJ_PREFIX=(.+)$/m);
@@ -31,24 +44,54 @@ function getScreenName() {
31
44
  return basename(process.cwd());
32
45
  }
33
46
 
47
+ function shellQuote(value) {
48
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
49
+ }
50
+
34
51
  function start(options = {}) {
52
+ const useScreen = !!options.screen;
53
+ const useTmux = !!options.tmux;
54
+
55
+ if (useScreen && useTmux) {
56
+ throw new Error("--screen and --tmux cannot be used together");
57
+ }
58
+
35
59
  const provider = getComposeProvider();
36
60
  const composeCmd = `${provider} -f kntic.yml --env-file .kntic.env up --build`;
37
- const useScreen = !!options.screen;
38
61
 
39
62
  if (useScreen && isScreenAvailable() && !isInsideScreen()) {
40
- const screenName = getScreenName();
41
- console.log(`Starting KNTIC services in screen session "${screenName}"…`);
42
- execSync(`screen -S ${screenName} ${composeCmd}`, { stdio: "inherit" });
43
- } else {
44
- if (useScreen && isInsideScreen()) {
45
- console.log("Already inside a screen session, skipping screen wrapper.");
46
- } else if (useScreen && !isScreenAvailable()) {
47
- console.log("screen is not available, starting without screen wrapper.");
48
- }
49
- console.log("Starting KNTIC services…");
50
- execSync(composeCmd, { stdio: "inherit" });
63
+ const sessionName = getSessionName();
64
+ console.log(`Starting KNTIC services in screen session "${sessionName}"…`);
65
+ execSync(`screen -S ${shellQuote(sessionName)} ${composeCmd}`, { stdio: "inherit" });
66
+ return;
51
67
  }
68
+
69
+ if (useTmux && isTmuxAvailable() && !isInsideTmux()) {
70
+ const sessionName = getSessionName();
71
+ console.log(`Starting KNTIC services in tmux session "${sessionName}"…`);
72
+ execSync(`tmux new-session -s ${shellQuote(sessionName)} ${shellQuote(composeCmd)}`, { stdio: "inherit" });
73
+ return;
74
+ }
75
+
76
+ if (useScreen && isInsideScreen()) {
77
+ console.log("Already inside a screen session, skipping screen wrapper.");
78
+ } else if (useScreen && !isScreenAvailable()) {
79
+ console.log("screen is not available, starting without screen wrapper.");
80
+ } else if (useTmux && isInsideTmux()) {
81
+ console.log("Already inside a tmux session, skipping tmux wrapper.");
82
+ } else if (useTmux && !isTmuxAvailable()) {
83
+ console.log("tmux is not available, starting without tmux wrapper.");
84
+ }
85
+
86
+ console.log("Starting KNTIC services…");
87
+ execSync(composeCmd, { stdio: "inherit" });
52
88
  }
53
89
 
54
- module.exports = start;
90
+ module.exports = Object.assign(start, {
91
+ isScreenAvailable,
92
+ isTmuxAvailable,
93
+ isInsideScreen,
94
+ isInsideTmux,
95
+ getSessionName,
96
+ shellQuote,
97
+ });
@@ -5,18 +5,38 @@ const assert = require("node:assert/strict");
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
7
  const os = require("os");
8
+ const childProcess = require("child_process");
8
9
 
9
- // Helper: extract a named function from start.js source by evaluating it in isolation.
10
- function extractFn(fnName) {
11
- const src = fs.readFileSync(require.resolve("./start"), "utf8");
12
- return new Function("require", "process",
13
- src.replace(/^"use strict";\s*/, "")
14
- .replace(/module\.exports\s*=\s*start;/, `return ${fnName};`)
15
- )(require, process);
10
+ function withMockedExecSync(mockImpl, run) {
11
+ const originalExecSync = childProcess.execSync;
12
+ childProcess.execSync = mockImpl;
13
+ delete require.cache[require.resolve("./start")];
14
+
15
+ try {
16
+ return run(require("./start"));
17
+ } finally {
18
+ childProcess.execSync = originalExecSync;
19
+ delete require.cache[require.resolve("./start")];
20
+ }
16
21
  }
17
22
 
18
- describe("start — getScreenName resolution", () => {
19
- let tmpDir, origCwd;
23
+ function captureConsoleLogs(run) {
24
+ const originalLog = console.log;
25
+ const logs = [];
26
+ console.log = (...args) => logs.push(args.join(" "));
27
+
28
+ try {
29
+ run(logs);
30
+ } finally {
31
+ console.log = originalLog;
32
+ }
33
+
34
+ return logs;
35
+ }
36
+
37
+ describe("start — session name resolution", () => {
38
+ let tmpDir;
39
+ let origCwd;
20
40
 
21
41
  beforeEach(() => {
22
42
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-start-test-"));
@@ -31,34 +51,31 @@ describe("start — getScreenName resolution", () => {
31
51
 
32
52
  it("uses KNTIC_PRJ_PREFIX from .kntic.env when present", () => {
33
53
  fs.writeFileSync(path.join(tmpDir, ".kntic.env"), "UID=1000\nKNTIC_PRJ_PREFIX=myproject\nGID=1000\n");
34
- delete require.cache[require.resolve("./start")];
35
- const getScreenName = extractFn("getScreenName");
36
- assert.equal(getScreenName(), "myproject");
54
+ const start = require("./start");
55
+ assert.equal(start.getSessionName(), "myproject");
37
56
  });
38
57
 
39
58
  it("falls back to current directory name when KNTIC_PRJ_PREFIX is missing", () => {
40
59
  fs.writeFileSync(path.join(tmpDir, ".kntic.env"), "UID=1000\nGID=1000\n");
41
- delete require.cache[require.resolve("./start")];
42
- const getScreenName = extractFn("getScreenName");
43
- assert.equal(getScreenName(), path.basename(tmpDir));
60
+ const start = require("./start");
61
+ assert.equal(start.getSessionName(), path.basename(tmpDir));
44
62
  });
45
63
 
46
64
  it("falls back to current directory name when .kntic.env does not exist", () => {
47
- delete require.cache[require.resolve("./start")];
48
- const getScreenName = extractFn("getScreenName");
49
- assert.equal(getScreenName(), path.basename(tmpDir));
65
+ const start = require("./start");
66
+ assert.equal(start.getSessionName(), path.basename(tmpDir));
50
67
  });
51
68
 
52
69
  it("ignores empty KNTIC_PRJ_PREFIX value", () => {
53
70
  fs.writeFileSync(path.join(tmpDir, ".kntic.env"), "KNTIC_PRJ_PREFIX=\nUID=1000\n");
54
- delete require.cache[require.resolve("./start")];
55
- const getScreenName = extractFn("getScreenName");
56
- assert.equal(getScreenName(), path.basename(tmpDir));
71
+ const start = require("./start");
72
+ assert.equal(start.getSessionName(), path.basename(tmpDir));
57
73
  });
58
74
  });
59
75
 
60
76
  describe("start — compose provider from .kntic.env", () => {
61
- let tmpDir, origCwd;
77
+ let tmpDir;
78
+ let origCwd;
62
79
 
63
80
  beforeEach(() => {
64
81
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-start-compose-"));
@@ -89,11 +106,13 @@ describe("start — compose provider from .kntic.env", () => {
89
106
  });
90
107
  });
91
108
 
92
- describe("start — isInsideScreen detection", () => {
109
+ describe("start — session detection helpers", () => {
93
110
  let origSTY;
111
+ let origTMUX;
94
112
 
95
113
  beforeEach(() => {
96
114
  origSTY = process.env.STY;
115
+ origTMUX = process.env.TMUX;
97
116
  });
98
117
 
99
118
  afterEach(() => {
@@ -102,19 +121,142 @@ describe("start — isInsideScreen detection", () => {
102
121
  } else {
103
122
  process.env.STY = origSTY;
104
123
  }
124
+
125
+ if (origTMUX === undefined) {
126
+ delete process.env.TMUX;
127
+ } else {
128
+ process.env.TMUX = origTMUX;
129
+ }
105
130
  });
106
131
 
107
132
  it("returns true when STY environment variable is set", () => {
108
133
  process.env.STY = "12345.myscreen";
109
- delete require.cache[require.resolve("./start")];
110
- const isInsideScreen = extractFn("isInsideScreen");
111
- assert.equal(isInsideScreen(), true);
134
+ const start = require("./start");
135
+ assert.equal(start.isInsideScreen(), true);
112
136
  });
113
137
 
114
138
  it("returns false when STY environment variable is not set", () => {
115
139
  delete process.env.STY;
116
- delete require.cache[require.resolve("./start")];
117
- const isInsideScreen = extractFn("isInsideScreen");
118
- assert.equal(isInsideScreen(), false);
140
+ const start = require("./start");
141
+ assert.equal(start.isInsideScreen(), false);
142
+ });
143
+
144
+ it("returns true when TMUX environment variable is set", () => {
145
+ process.env.TMUX = "/tmp/tmux-1000/default,123,0";
146
+ const start = require("./start");
147
+ assert.equal(start.isInsideTmux(), true);
148
+ });
149
+
150
+ it("returns false when TMUX environment variable is not set", () => {
151
+ delete process.env.TMUX;
152
+ const start = require("./start");
153
+ assert.equal(start.isInsideTmux(), false);
154
+ });
155
+ });
156
+
157
+ describe("start — tmux wrapper behavior", () => {
158
+ let tmpDir;
159
+ let origCwd;
160
+ let origTMUX;
161
+
162
+ beforeEach(() => {
163
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-start-tmux-"));
164
+ origCwd = process.cwd();
165
+ origTMUX = process.env.TMUX;
166
+ process.chdir(tmpDir);
167
+ delete process.env.TMUX;
168
+ fs.writeFileSync(path.join(tmpDir, ".kntic.env"), "KNTIC_COMPOSE_PROVIDER=docker compose\nKNTIC_PRJ_PREFIX=demo\n");
169
+ });
170
+
171
+ afterEach(() => {
172
+ process.chdir(origCwd);
173
+ fs.rmSync(tmpDir, { recursive: true, force: true });
174
+
175
+ if (origTMUX === undefined) {
176
+ delete process.env.TMUX;
177
+ } else {
178
+ process.env.TMUX = origTMUX;
179
+ }
180
+ });
181
+
182
+ it("starts inside tmux when tmux is available and not already inside tmux", () => {
183
+ const calls = [];
184
+
185
+ const logs = captureConsoleLogs(() => {
186
+ withMockedExecSync((command, options) => {
187
+ calls.push({ command, options });
188
+ return Buffer.from("");
189
+ }, (start) => {
190
+ start({ tmux: true });
191
+ });
192
+ });
193
+
194
+ assert.equal(calls.length, 2);
195
+ assert.equal(calls[0].command, "which tmux");
196
+ assert.match(calls[1].command, /^tmux new-session -s 'demo' 'docker compose -f kntic\.yml --env-file \.kntic\.env up --build'$/);
197
+ assert.equal(calls[1].options.stdio, "inherit");
198
+ assert.deepEqual(logs, ['Starting KNTIC services in tmux session "demo"…']);
199
+ });
200
+
201
+ it("falls back to foreground mode when tmux is unavailable", () => {
202
+ const calls = [];
203
+
204
+ const logs = captureConsoleLogs(() => {
205
+ withMockedExecSync((command, options) => {
206
+ calls.push({ command, options });
207
+ if (command === "which tmux") {
208
+ throw new Error("tmux not found");
209
+ }
210
+ return Buffer.from("");
211
+ }, (start) => {
212
+ start({ tmux: true });
213
+ });
214
+ });
215
+
216
+ assert.equal(calls.length, 3);
217
+ assert.equal(calls[0].command, "which tmux");
218
+ assert.equal(calls[1].command, "which tmux");
219
+ assert.equal(calls[2].command, "docker compose -f kntic.yml --env-file .kntic.env up --build");
220
+ assert.deepEqual(logs, [
221
+ "tmux is not available, starting without tmux wrapper.",
222
+ "Starting KNTIC services…",
223
+ ]);
224
+ });
225
+
226
+ it("bypasses tmux wrapper when already inside tmux", () => {
227
+ process.env.TMUX = "/tmp/tmux-1000/default,456,0";
228
+ const calls = [];
229
+
230
+ const logs = captureConsoleLogs(() => {
231
+ withMockedExecSync((command, options) => {
232
+ calls.push({ command, options });
233
+ return Buffer.from("");
234
+ }, (start) => {
235
+ start({ tmux: true });
236
+ });
237
+ });
238
+
239
+ assert.equal(calls.length, 2);
240
+ assert.equal(calls[0].command, "which tmux");
241
+ assert.equal(calls[1].command, "docker compose -f kntic.yml --env-file .kntic.env up --build");
242
+ assert.deepEqual(logs, [
243
+ "Already inside a tmux session, skipping tmux wrapper.",
244
+ "Starting KNTIC services…",
245
+ ]);
246
+ });
247
+
248
+ it("rejects using --screen and --tmux together", () => {
249
+ const calls = [];
250
+
251
+ assert.throws(() => {
252
+ withMockedExecSync((command) => {
253
+ calls.push(command);
254
+ return Buffer.from("");
255
+ }, (start) => {
256
+ start({ screen: true, tmux: true });
257
+ });
258
+ }, /--screen and --tmux cannot be used together/);
259
+
260
+ assert.deepEqual(calls, []);
119
261
  });
120
262
  });
@@ -405,60 +405,74 @@ function extractCompose(tarball, destDir) {
405
405
  console.log("Updated kntic.yml from bootstrap template.");
406
406
  }
407
407
 
408
- async function update(options = {}) {
408
+ async function update(options = {}, deps = {}) {
409
409
  const libOnly = options.libOnly || false;
410
+ const composeFlagUsed = options.compose || false;
411
+ const fetchTextImpl = deps.fetchText || fetchText;
412
+ const downloadImpl = deps.download || download;
413
+ const extractLibOnlyImpl = deps.extractLibOnly || extractLibOnly;
414
+ const extractUpdateImpl = deps.extractUpdate || extractUpdate;
415
+ const mergeEnvFileImpl = deps.mergeEnvFile || mergeEnvFile;
416
+ const extractComposeImpl = deps.extractCompose || extractCompose;
417
+ const updateEnvVersionImpl = deps.updateEnvVersion || updateEnvVersion;
418
+
419
+ if (composeFlagUsed) {
420
+ console.warn("Warning: --compose is deprecated because kntic.yml is now included in the default update path.");
421
+ if (libOnly) {
422
+ console.log("Info: --lib-only skips kntic.yml updates.");
423
+ }
424
+ }
410
425
 
411
426
  // Resolve current version from the artifact metadata file
412
- const artifactFilename = await fetchText(BOOTSTRAP_ARTIFACT_URL);
427
+ const artifactFilename = await fetchTextImpl(BOOTSTRAP_ARTIFACT_URL);
413
428
  const version = extractVersion(artifactFilename);
414
429
 
415
430
  const tmpFile = path.join(os.tmpdir(), `kntic-bootstrap-${Date.now()}.tar.gz`);
416
431
 
417
432
  console.log(`Downloading KNTIC bootstrap archive… (v${version})`);
418
- await download(BOOTSTRAP_URL, tmpFile);
433
+ await downloadImpl(BOOTSTRAP_URL, tmpFile);
419
434
 
420
435
  if (libOnly) {
421
436
  console.log("Updating .kntic/lib …");
422
- extractLibOnly(tmpFile, ".");
437
+ extractLibOnlyImpl(tmpFile, ".");
423
438
  } else {
424
439
  console.log("Updating .kntic/lib, .kntic/adrs, .kntic/hooks/gia/internal, .kntic/hooks/gia/specific (if new), and .kntic/gia/weights.json …");
425
- extractUpdate(tmpFile, ".");
440
+ extractUpdateImpl(tmpFile, ".");
426
441
  }
427
442
 
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-"));
443
+ if (!libOnly) {
444
+ // Merge new env variables from the archive's .kntic.env template
445
+ const timestamp = Date.now();
446
+ const tmpEnvTemplate = path.join(os.tmpdir(), `kntic-env-template-${timestamp}`);
433
447
  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 {
448
+ const tmpExtractDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-env-extract-"));
449
+ try {
450
+ execSync(`tar xzf "${tmpFile}" -C "${tmpExtractDir}" "./.kntic.env"`, { stdio: "pipe" });
451
+ const extractedEnv = path.join(tmpExtractDir, ".kntic.env");
452
+ if (fs.existsSync(extractedEnv)) {
453
+ fs.copyFileSync(extractedEnv, tmpEnvTemplate);
454
+ mergeEnvFileImpl(tmpEnvTemplate, ".kntic.env");
455
+ } else {
456
+ console.log("No .kntic.env template in archive, skipping env merge.");
457
+ }
458
+ } catch (_) {
440
459
  console.log("No .kntic.env template in archive, skipping env merge.");
460
+ } finally {
461
+ fs.rmSync(tmpExtractDir, { recursive: true, force: true });
441
462
  }
442
- } catch (_) {
443
- console.log("No .kntic.env template in archive, skipping env merge.");
444
463
  } finally {
445
- fs.rmSync(tmpExtractDir, { recursive: true, force: true });
464
+ try { fs.unlinkSync(tmpEnvTemplate); } catch (_) {}
446
465
  }
447
- } finally {
448
- try { fs.unlinkSync(tmpEnvTemplate); } catch (_) {}
449
- }
450
466
 
451
- // Replace kntic.yml from archive if --compose flag is set
452
- if (options.compose) {
453
467
  try {
454
- extractCompose(tmpFile, ".");
468
+ extractComposeImpl(tmpFile, ".");
455
469
  } catch (_) {
456
470
  console.log("No kntic.yml in archive, skipping compose update.");
457
471
  }
458
472
  }
459
473
 
460
474
  // Update KNTIC_VERSION in .kntic.env
461
- updateEnvVersion(version);
475
+ updateEnvVersionImpl(version);
462
476
 
463
477
  // Clean up
464
478
  fs.unlinkSync(tmpFile);
@@ -467,7 +481,7 @@ async function update(options = {}) {
467
481
  if (libOnly) {
468
482
  console.log("Done. .kntic/lib updated successfully.");
469
483
  } else {
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.");
484
+ console.log("Done. .kntic/lib, .kntic/adrs, .kntic/hooks/gia/internal, .kntic/hooks/gia/specific (if new), .kntic/gia/weights.json, and kntic.yml updated successfully.");
471
485
  }
472
486
  }
473
487
 
@@ -7,7 +7,9 @@ const path = require("path");
7
7
  const os = require("os");
8
8
  const { execSync } = require("child_process");
9
9
 
10
- const { extractLibOnly, extractUpdate, extractCompose, extractVersion, clearDirectory, updateEnvVersion, mergeEnvFile } = require("./update");
10
+ const update = require("./update");
11
+ const usage = require("./usage");
12
+ const { extractLibOnly, extractUpdate, extractCompose, extractVersion, clearDirectory, updateEnvVersion, mergeEnvFile } = update;
11
13
 
12
14
  /**
13
15
  * Helper — create a tar.gz archive in `tmpDir` containing the given files.
@@ -727,6 +729,148 @@ describe("mergeEnvFile", () => {
727
729
  });
728
730
  });
729
731
 
732
+ describe("update", () => {
733
+ let tmpDir;
734
+ let cwd;
735
+ let originalLog;
736
+ let originalWarn;
737
+ let logs;
738
+ let warnings;
739
+
740
+ beforeEach(() => {
741
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-test-update-flow-"));
742
+ cwd = process.cwd();
743
+ process.chdir(tmpDir);
744
+ logs = [];
745
+ warnings = [];
746
+ originalLog = console.log;
747
+ originalWarn = console.warn;
748
+ console.log = (message) => logs.push(message);
749
+ console.warn = (message) => warnings.push(message);
750
+ });
751
+
752
+ afterEach(() => {
753
+ console.log = originalLog;
754
+ console.warn = originalWarn;
755
+ process.chdir(cwd);
756
+ fs.rmSync(tmpDir, { recursive: true, force: true });
757
+ });
758
+
759
+ async function runUpdate(options = {}, tarballFiles = {}) {
760
+ const tarball = createTarball(tmpDir, tarballFiles);
761
+ await update(options, {
762
+ fetchText: async () => "kntic-bootstrap-v1.2.3.tar.gz",
763
+ download: async (_url, dest) => fs.copyFileSync(tarball, dest),
764
+ });
765
+ }
766
+
767
+ it("replaces kntic.yml by default and creates a backup", async () => {
768
+ fs.writeFileSync("kntic.yml", "services:\n app:\n image: old\n");
769
+ fs.writeFileSync(".kntic.env", "UID=1000\n");
770
+
771
+ await runUpdate({}, {
772
+ ".kntic/lib/orchestrator.py": "# new lib\n",
773
+ ".kntic/adrs/ADR-001.md": "# adr\n",
774
+ ".kntic.env": "UID=1000\nNEW_VAR=yes\n",
775
+ "kntic.yml": "services:\n app:\n image: new\n",
776
+ });
777
+
778
+ assert.equal(fs.readFileSync("kntic.yml", "utf8"), "services:\n app:\n image: new\n");
779
+ assert.equal(fs.readFileSync("kntic.yml.bak", "utf8"), "services:\n app:\n image: old\n");
780
+ assert.ok(logs.includes("Backing up kntic.yml → kntic.yml.bak"));
781
+ assert.ok(logs.includes("Updated kntic.yml from bootstrap template."));
782
+ });
783
+
784
+ it("prints one deprecation warning for --compose without doing duplicate compose work", async () => {
785
+ fs.writeFileSync("kntic.yml", "services:\n app:\n image: old\n");
786
+ fs.writeFileSync(".kntic.env", "UID=1000\n");
787
+
788
+ await runUpdate({ compose: true }, {
789
+ ".kntic/lib/orchestrator.py": "# new lib\n",
790
+ ".kntic/adrs/ADR-001.md": "# adr\n",
791
+ ".kntic.env": "UID=1000\n",
792
+ "kntic.yml": "services:\n app:\n image: new\n",
793
+ });
794
+
795
+ assert.deepEqual(warnings, [
796
+ "Warning: --compose is deprecated because kntic.yml is now included in the default update path.",
797
+ ]);
798
+ assert.equal(
799
+ logs.filter((message) => message === "Backing up kntic.yml → kntic.yml.bak").length,
800
+ 1,
801
+ "compose backup must run only once"
802
+ );
803
+ assert.equal(
804
+ logs.filter((message) => message === "Updated kntic.yml from bootstrap template.").length,
805
+ 1,
806
+ "compose extraction must run only once"
807
+ );
808
+ });
809
+
810
+ it("keeps --lib-only strict and skips kntic.yml replacement", async () => {
811
+ fs.writeFileSync("kntic.yml", "services:\n app:\n image: old\n");
812
+ fs.writeFileSync(".kntic.env", "UID=1000\n");
813
+
814
+ await runUpdate({ libOnly: true }, {
815
+ ".kntic/lib/orchestrator.py": "# new lib\n",
816
+ ".kntic/adrs/ADR-001.md": "# adr\n",
817
+ ".kntic.env": "UID=1000\nNEW_VAR=yes\n",
818
+ "kntic.yml": "services:\n app:\n image: new\n",
819
+ });
820
+
821
+ assert.equal(fs.readFileSync("kntic.yml", "utf8"), "services:\n app:\n image: old\n");
822
+ assert.ok(!fs.existsSync("kntic.yml.bak"), "backup must not be created in lib-only mode");
823
+ assert.equal(fs.readFileSync(".kntic.env", "utf8"), "UID=1000\nKNTIC_VERSION=1.2.3\n");
824
+ assert.equal(
825
+ logs.filter((message) => message === "Updated kntic.yml from bootstrap template.").length,
826
+ 0,
827
+ "compose extraction must be skipped in lib-only mode"
828
+ );
829
+ });
830
+
831
+ it("does not replace kntic.yml for --lib-only --compose, but warns clearly", async () => {
832
+ fs.writeFileSync("kntic.yml", "services:\n app:\n image: old\n");
833
+ fs.writeFileSync(".kntic.env", "UID=1000\n");
834
+
835
+ await runUpdate({ libOnly: true, compose: true }, {
836
+ ".kntic/lib/orchestrator.py": "# new lib\n",
837
+ ".kntic/adrs/ADR-001.md": "# adr\n",
838
+ ".kntic.env": "UID=1000\nNEW_VAR=yes\n",
839
+ "kntic.yml": "services:\n app:\n image: new\n",
840
+ });
841
+
842
+ assert.equal(fs.readFileSync("kntic.yml", "utf8"), "services:\n app:\n image: old\n");
843
+ assert.ok(!fs.existsSync("kntic.yml.bak"), "backup must not be created in lib-only mode");
844
+ assert.deepEqual(warnings, [
845
+ "Warning: --compose is deprecated because kntic.yml is now included in the default update path.",
846
+ ]);
847
+ assert.ok(logs.includes("Info: --lib-only skips kntic.yml updates."));
848
+ assert.equal(
849
+ logs.filter((message) => message === "Updated kntic.yml from bootstrap template.").length,
850
+ 0,
851
+ "compose extraction must still be skipped when --compose is combined with --lib-only"
852
+ );
853
+ });
854
+ });
855
+
856
+ describe("usage", () => {
857
+ it("documents kntic.yml as part of the default update path and marks --compose deprecated", () => {
858
+ const originalLog = console.log;
859
+ const lines = [];
860
+ console.log = (message) => lines.push(message);
861
+
862
+ try {
863
+ usage();
864
+ } finally {
865
+ console.log = originalLog;
866
+ }
867
+
868
+ assert.ok(lines.some((line) => line.includes("and kntic.yml")), "usage must mention default kntic.yml updates");
869
+ assert.ok(lines.some((line) => line.includes("--compose") && line.includes("Deprecated alias")), "usage must mark --compose deprecated");
870
+ assert.ok(lines.some((line) => line.includes("--lib-only") && line.includes("kntic.yml")), "usage must explain that --lib-only skips kntic.yml");
871
+ });
872
+ });
873
+
730
874
  describe("extractCompose", () => {
731
875
  let tmpDir;
732
876
  let destDir;
@@ -7,13 +7,14 @@ function usage() {
7
7
  console.log(" usage List all available sub-commands");
8
8
  console.log(" init Download and extract the KNTIC bootstrap template into the current directory");
9
9
  console.log(" --interactive Default mode — walk through .kntic.env values interactively (-i)");
10
- console.log(" --quick Non-interactive mode, auto-detects git remote only (-q)");
10
+ console.log(" --quick Non-interactive mode, auto-detects git remote info and compose provider (-q)");
11
11
  console.log(" start Build and start KNTIC services via docker compose (uses kntic.yml + .kntic.env)");
12
- console.log(" --screen Run inside a GNU screen session");
12
+ console.log(" --screen Run inside a GNU screen session wrapper");
13
+ console.log(" --tmux Run inside a tmux session wrapper (alternative to --screen)");
13
14
  console.log(" stop Stop KNTIC services via docker compose");
14
- 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");
15
- console.log(" --lib-only Update only .kntic/lib (skip adrs, hooks, and weights)");
16
- console.log(" --compose Also replace kntic.yml from the bootstrap template (backs up to kntic.yml.bak)");
15
+ console.log(" update Download the latest KNTIC bootstrap and update .kntic/lib, .kntic/adrs, .kntic/hooks/gia/internal, .kntic/hooks/gia/specific (if new), .kntic/gia/weights.json, and kntic.yml");
16
+ console.log(" --lib-only Update only .kntic/lib (skips adrs, hooks, env merge, weights, and kntic.yml)");
17
+ console.log(" --compose Deprecated alias; kntic.yml is already included in the default update path");
17
18
  console.log("");
18
19
  }
19
20