@kntic/kntic 0.2.1 → 0.3.1

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.2.1",
3
+ "version": "0.3.1",
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
@@ -21,6 +21,11 @@ if (!subcommand || subcommand === "usage") {
21
21
  console.error(`Error: ${err.message}`);
22
22
  process.exit(1);
23
23
  }
24
+ } else if (subcommand === "update") {
25
+ commands.update().catch((err) => {
26
+ console.error(`Error: ${err.message}`);
27
+ process.exit(1);
28
+ });
24
29
  } else {
25
30
  console.error(`Unknown command: "${subcommand}"\n`);
26
31
  commands.usage();
@@ -3,5 +3,6 @@
3
3
  const usage = require("./usage");
4
4
  const init = require("./init");
5
5
  const start = require("./start");
6
+ const update = require("./update");
6
7
 
7
- module.exports = { usage, init, start };
8
+ module.exports = { usage, init, start, update };
@@ -111,6 +111,50 @@ function download(url, dest, maxRedirects = 5) {
111
111
  });
112
112
  }
113
113
 
114
+ /**
115
+ * Extract a tarball into `destDir`, merging `.gitignore` instead of
116
+ * overwriting it. If `.gitignore` already exists in `destDir`, the
117
+ * archive's version is appended (with a separator comment); otherwise
118
+ * the file is extracted normally alongside everything else.
119
+ *
120
+ * @param {string} tarball – path to the .tar.gz file
121
+ * @param {string} destDir – target directory (usually ".")
122
+ */
123
+ function extractArchive(tarball, destDir) {
124
+ const gitignorePath = path.join(destDir, ".gitignore");
125
+ const gitignoreExists = fs.existsSync(gitignorePath);
126
+
127
+ if (gitignoreExists) {
128
+ // 1. Extract everything *except* .gitignore
129
+ execSync(
130
+ `tar xzf "${tarball}" -C "${destDir}" --exclude=".gitignore"`,
131
+ { stdio: "inherit" }
132
+ );
133
+
134
+ // 2. Read .gitignore content from the archive (may not exist in archive)
135
+ let archiveGitignore;
136
+ try {
137
+ archiveGitignore = execSync(
138
+ `tar xzf "${tarball}" --to-stdout .gitignore 2>/dev/null || tar xzf "${tarball}" --to-stdout "./.gitignore" 2>/dev/null`,
139
+ { encoding: "utf8" }
140
+ );
141
+ } catch {
142
+ // Archive does not contain a .gitignore — nothing to merge
143
+ archiveGitignore = null;
144
+ }
145
+
146
+ if (archiveGitignore && archiveGitignore.trim().length > 0) {
147
+ const existing = fs.readFileSync(gitignorePath, "utf8");
148
+ const separator = "\n# --- kntic bootstrap ---\n";
149
+ const merged = existing.trimEnd() + separator + archiveGitignore.trimEnd() + "\n";
150
+ fs.writeFileSync(gitignorePath, merged);
151
+ }
152
+ } else {
153
+ // No existing .gitignore — plain extraction
154
+ execSync(`tar xzf "${tarball}" -C "${destDir}"`, { stdio: "inherit" });
155
+ }
156
+ }
157
+
114
158
  async function init() {
115
159
  // Resolve current version from the artifact metadata file
116
160
  const artifactFilename = await fetchText(BOOTSTRAP_ARTIFACT_URL);
@@ -122,7 +166,7 @@ async function init() {
122
166
  await download(BOOTSTRAP_URL, tmpFile);
123
167
 
124
168
  console.log("Extracting into current directory…");
125
- execSync(`tar xzf "${tmpFile}" -C .`, { stdio: "inherit" });
169
+ extractArchive(tmpFile, ".");
126
170
 
127
171
  // Append version to .kntic.env (project root) — do not replace existing content
128
172
  fs.appendFileSync(".kntic.env", `KNTIC_VERSION=${version}\n`);
@@ -134,3 +178,5 @@ async function init() {
134
178
  }
135
179
 
136
180
  module.exports = init;
181
+ module.exports.extractArchive = extractArchive;
182
+ module.exports.extractVersion = extractVersion;
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+
3
+ const { describe, it, beforeEach, afterEach } = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const os = require("os");
8
+ const { execSync } = require("child_process");
9
+
10
+ const { extractArchive, extractVersion } = require("./init");
11
+
12
+ /**
13
+ * Helper — create a tar.gz archive in `tmpDir` containing the given files.
14
+ * `files` is an object like { ".gitignore": "node_modules/\n", "README.md": "# Hi" }
15
+ */
16
+ function createTarball(tmpDir, files) {
17
+ const contentDir = path.join(tmpDir, "_archive_content");
18
+ fs.mkdirSync(contentDir, { recursive: true });
19
+
20
+ for (const [name, content] of Object.entries(files)) {
21
+ const filePath = path.join(contentDir, name);
22
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
23
+ fs.writeFileSync(filePath, content);
24
+ }
25
+
26
+ const tarball = path.join(tmpDir, "test.tar.gz");
27
+ execSync(`tar czf "${tarball}" -C "${contentDir}" .`, { stdio: "pipe" });
28
+ return tarball;
29
+ }
30
+
31
+ describe("extractArchive", () => {
32
+ let tmpDir;
33
+ let destDir;
34
+
35
+ beforeEach(() => {
36
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-test-"));
37
+ destDir = path.join(tmpDir, "dest");
38
+ fs.mkdirSync(destDir, { recursive: true });
39
+ });
40
+
41
+ afterEach(() => {
42
+ fs.rmSync(tmpDir, { recursive: true, force: true });
43
+ });
44
+
45
+ it("extracts all files when .gitignore does not exist", () => {
46
+ const tarball = createTarball(tmpDir, {
47
+ ".gitignore": "node_modules/\n",
48
+ "README.md": "# Hello\n",
49
+ });
50
+
51
+ extractArchive(tarball, destDir);
52
+
53
+ assert.equal(
54
+ fs.readFileSync(path.join(destDir, ".gitignore"), "utf8"),
55
+ "node_modules/\n"
56
+ );
57
+ assert.equal(
58
+ fs.readFileSync(path.join(destDir, "README.md"), "utf8"),
59
+ "# Hello\n"
60
+ );
61
+ });
62
+
63
+ it("appends archive .gitignore to existing one", () => {
64
+ // Pre-existing .gitignore
65
+ fs.writeFileSync(
66
+ path.join(destDir, ".gitignore"),
67
+ "*.log\n.env\n"
68
+ );
69
+
70
+ const tarball = createTarball(tmpDir, {
71
+ ".gitignore": "node_modules/\ndist/\n",
72
+ "README.md": "# Hello\n",
73
+ });
74
+
75
+ extractArchive(tarball, destDir);
76
+
77
+ const result = fs.readFileSync(path.join(destDir, ".gitignore"), "utf8");
78
+
79
+ // Original content must still be present
80
+ assert.ok(result.includes("*.log"), "original entry *.log must be preserved");
81
+ assert.ok(result.includes(".env"), "original entry .env must be preserved");
82
+
83
+ // Archive content must be appended
84
+ assert.ok(result.includes("node_modules/"), "archive entry node_modules/ must be appended");
85
+ assert.ok(result.includes("dist/"), "archive entry dist/ must be appended");
86
+
87
+ // Separator comment must be present
88
+ assert.ok(
89
+ result.includes("# --- kntic bootstrap ---"),
90
+ "separator comment must be present between original and appended content"
91
+ );
92
+
93
+ // Other files still extracted
94
+ assert.equal(
95
+ fs.readFileSync(path.join(destDir, "README.md"), "utf8"),
96
+ "# Hello\n"
97
+ );
98
+ });
99
+
100
+ it("leaves existing .gitignore unchanged when archive has none", () => {
101
+ fs.writeFileSync(
102
+ path.join(destDir, ".gitignore"),
103
+ "*.log\n"
104
+ );
105
+
106
+ const tarball = createTarball(tmpDir, {
107
+ "README.md": "# Hello\n",
108
+ });
109
+
110
+ extractArchive(tarball, destDir);
111
+
112
+ assert.equal(
113
+ fs.readFileSync(path.join(destDir, ".gitignore"), "utf8"),
114
+ "*.log\n"
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("extractVersion", () => {
120
+ it("parses version from artifact filename", () => {
121
+ assert.equal(extractVersion("kntic-bootstrap-v0.0.10.tar.gz"), "0.0.10");
122
+ assert.equal(extractVersion("kntic-bootstrap-v1.2.3.tar.gz"), "1.2.3");
123
+ });
124
+
125
+ it("throws on invalid filename", () => {
126
+ assert.throws(() => extractVersion("no-version-here.tar.gz"));
127
+ });
128
+ });
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+
3
+ const https = require("https");
4
+ const http = require("http");
5
+ const { execSync } = require("child_process");
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const os = require("os");
9
+
10
+ const BOOTSTRAP_ARTIFACT_URL =
11
+ "https://minio.kommune7.wien/public/kntic-bootstrap-latest.artifact";
12
+ const BOOTSTRAP_URL =
13
+ "https://minio.kommune7.wien/public/kntic-bootstrap-latest.tar.gz";
14
+
15
+ /**
16
+ * Fetch a small text resource from a URL (follows redirects).
17
+ * Returns the response body as a trimmed string.
18
+ */
19
+ function fetchText(url, maxRedirects = 5) {
20
+ return new Promise((resolve, reject) => {
21
+ if (maxRedirects <= 0) {
22
+ return reject(new Error("Too many redirects"));
23
+ }
24
+
25
+ const client = url.startsWith("https") ? https : http;
26
+
27
+ client
28
+ .get(url, (res) => {
29
+ if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
30
+ const location = res.headers.location;
31
+ if (!location) {
32
+ return reject(new Error("Redirect with no Location header"));
33
+ }
34
+ res.resume();
35
+ return resolve(fetchText(location, maxRedirects - 1));
36
+ }
37
+
38
+ if (res.statusCode !== 200) {
39
+ res.resume();
40
+ return reject(
41
+ new Error(`Fetch failed — HTTP ${res.statusCode}`)
42
+ );
43
+ }
44
+
45
+ const chunks = [];
46
+ res.on("data", (chunk) => chunks.push(chunk));
47
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8").trim()));
48
+ res.on("error", reject);
49
+ })
50
+ .on("error", reject);
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Follow redirects and download a URL to a local file path.
56
+ */
57
+ function download(url, dest, maxRedirects = 5) {
58
+ return new Promise((resolve, reject) => {
59
+ if (maxRedirects <= 0) {
60
+ return reject(new Error("Too many redirects"));
61
+ }
62
+
63
+ const client = url.startsWith("https") ? https : http;
64
+
65
+ client
66
+ .get(url, (res) => {
67
+ if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
68
+ const location = res.headers.location;
69
+ if (!location) {
70
+ return reject(new Error("Redirect with no Location header"));
71
+ }
72
+ res.resume();
73
+ return resolve(download(location, dest, maxRedirects - 1));
74
+ }
75
+
76
+ if (res.statusCode !== 200) {
77
+ res.resume();
78
+ return reject(
79
+ new Error(`Download failed — HTTP ${res.statusCode}`)
80
+ );
81
+ }
82
+
83
+ const file = fs.createWriteStream(dest);
84
+ res.pipe(file);
85
+ file.on("finish", () => file.close(resolve));
86
+ file.on("error", (err) => {
87
+ fs.unlink(dest, () => {});
88
+ reject(err);
89
+ });
90
+ })
91
+ .on("error", (err) => {
92
+ fs.unlink(dest, () => {});
93
+ reject(err);
94
+ });
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Extract semantic version from an artifact filename.
100
+ * E.g. "kntic-bootstrap-v0.0.10.tar.gz" → "0.0.10"
101
+ */
102
+ function extractVersion(artifactFilename) {
103
+ const match = artifactFilename.match(/v(\d+\.\d+\.\d+)/);
104
+ if (!match) {
105
+ throw new Error(
106
+ `Unable to extract version from artifact filename: ${artifactFilename}`
107
+ );
108
+ }
109
+ return match[1];
110
+ }
111
+
112
+ /**
113
+ * Update or create the KNTIC_VERSION variable in a .kntic.env file.
114
+ * If the file exists and contains a KNTIC_VERSION line, that line is replaced.
115
+ * If the file exists but has no KNTIC_VERSION, it is appended.
116
+ * If the file does not exist, it is created with the variable.
117
+ *
118
+ * @param {string} version – semantic version string (e.g. "0.0.10")
119
+ * @param {string} envPath – path to .kntic.env (default: ".kntic.env")
120
+ */
121
+ function updateEnvVersion(version, envPath = ".kntic.env") {
122
+ const line = `KNTIC_VERSION=${version}`;
123
+
124
+ if (!fs.existsSync(envPath)) {
125
+ fs.writeFileSync(envPath, line + "\n");
126
+ return;
127
+ }
128
+
129
+ const content = fs.readFileSync(envPath, "utf8");
130
+ const regex = /^KNTIC_VERSION=.*$/m;
131
+
132
+ if (regex.test(content)) {
133
+ const updated = content.replace(regex, line);
134
+ fs.writeFileSync(envPath, updated);
135
+ } else {
136
+ const separator = content.endsWith("\n") ? "" : "\n";
137
+ fs.writeFileSync(envPath, content + separator + line + "\n");
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Recursively remove all contents of a directory (but not the directory itself).
143
+ */
144
+ function clearDirectory(dirPath) {
145
+ if (!fs.existsSync(dirPath)) {
146
+ return;
147
+ }
148
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
149
+ for (const entry of entries) {
150
+ const fullPath = path.join(dirPath, entry.name);
151
+ if (entry.isDirectory()) {
152
+ fs.rmSync(fullPath, { recursive: true, force: true });
153
+ } else {
154
+ fs.unlinkSync(fullPath);
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Extract only .kntic/lib/** from a tarball into the destination directory.
161
+ * Clears the existing .kntic/lib directory first, then extracts fresh content.
162
+ *
163
+ * @param {string} tarball – path to the .tar.gz file
164
+ * @param {string} destDir – target directory (usually ".")
165
+ */
166
+ function extractLibOnly(tarball, destDir) {
167
+ const libDir = path.join(destDir, ".kntic", "lib");
168
+
169
+ // Ensure the target directory exists
170
+ fs.mkdirSync(libDir, { recursive: true });
171
+
172
+ // Clear existing lib contents
173
+ clearDirectory(libDir);
174
+
175
+ // Extract only .kntic/lib/ from the archive.
176
+ // Tar entries are prefixed with ./ (created with `tar -C dir .`), so we
177
+ // pass the exact member path `./.kntic/lib/` which matches the directory
178
+ // and all its descendants.
179
+ execSync(
180
+ `tar xzf "${tarball}" -C "${destDir}" "./.kntic/lib/"`,
181
+ { stdio: "pipe" }
182
+ );
183
+ }
184
+
185
+ async function update() {
186
+ // Resolve current version from the artifact metadata file
187
+ const artifactFilename = await fetchText(BOOTSTRAP_ARTIFACT_URL);
188
+ const version = extractVersion(artifactFilename);
189
+
190
+ const tmpFile = path.join(os.tmpdir(), `kntic-bootstrap-${Date.now()}.tar.gz`);
191
+
192
+ console.log(`Downloading KNTIC bootstrap archive… (v${version})`);
193
+ await download(BOOTSTRAP_URL, tmpFile);
194
+
195
+ console.log("Updating .kntic/lib …");
196
+ extractLibOnly(tmpFile, ".");
197
+
198
+ // Update KNTIC_VERSION in .kntic.env
199
+ updateEnvVersion(version);
200
+
201
+ // Clean up
202
+ fs.unlinkSync(tmpFile);
203
+
204
+ console.log(`@kntic/kntic@${version}`);
205
+ console.log("Done. .kntic/lib updated successfully.");
206
+ }
207
+
208
+ module.exports = update;
209
+ module.exports.extractLibOnly = extractLibOnly;
210
+ module.exports.extractVersion = extractVersion;
211
+ module.exports.clearDirectory = clearDirectory;
212
+ module.exports.updateEnvVersion = updateEnvVersion;
@@ -0,0 +1,271 @@
1
+ "use strict";
2
+
3
+ const { describe, it, beforeEach, afterEach } = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const os = require("os");
8
+ const { execSync } = require("child_process");
9
+
10
+ const { extractLibOnly, extractVersion, clearDirectory, updateEnvVersion } = require("./update");
11
+
12
+ /**
13
+ * Helper — create a tar.gz archive in `tmpDir` containing the given files.
14
+ * `files` is an object like { ".kntic/lib/foo.py": "content", "README.md": "# Hi" }
15
+ */
16
+ function createTarball(tmpDir, files) {
17
+ const contentDir = path.join(tmpDir, "_archive_content");
18
+ fs.mkdirSync(contentDir, { recursive: true });
19
+
20
+ for (const [name, content] of Object.entries(files)) {
21
+ const filePath = path.join(contentDir, name);
22
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
23
+ fs.writeFileSync(filePath, content);
24
+ }
25
+
26
+ const tarball = path.join(tmpDir, "test.tar.gz");
27
+ execSync(`tar czf "${tarball}" -C "${contentDir}" .`, { stdio: "pipe" });
28
+ return tarball;
29
+ }
30
+
31
+ describe("clearDirectory", () => {
32
+ let tmpDir;
33
+
34
+ beforeEach(() => {
35
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-test-clear-"));
36
+ });
37
+
38
+ afterEach(() => {
39
+ fs.rmSync(tmpDir, { recursive: true, force: true });
40
+ });
41
+
42
+ it("removes all files and subdirectories but keeps the directory itself", () => {
43
+ const dir = path.join(tmpDir, "target");
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ fs.writeFileSync(path.join(dir, "a.txt"), "hello");
46
+ fs.mkdirSync(path.join(dir, "sub"), { recursive: true });
47
+ fs.writeFileSync(path.join(dir, "sub", "b.txt"), "world");
48
+
49
+ clearDirectory(dir);
50
+
51
+ assert.ok(fs.existsSync(dir), "directory itself must still exist");
52
+ assert.deepEqual(fs.readdirSync(dir), [], "directory must be empty");
53
+ });
54
+
55
+ it("does nothing if directory does not exist", () => {
56
+ // Should not throw
57
+ clearDirectory(path.join(tmpDir, "nonexistent"));
58
+ });
59
+ });
60
+
61
+ describe("extractLibOnly", () => {
62
+ let tmpDir;
63
+ let destDir;
64
+
65
+ beforeEach(() => {
66
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-test-update-"));
67
+ destDir = path.join(tmpDir, "dest");
68
+ fs.mkdirSync(destDir, { recursive: true });
69
+ });
70
+
71
+ afterEach(() => {
72
+ fs.rmSync(tmpDir, { recursive: true, force: true });
73
+ });
74
+
75
+ it("extracts only .kntic/lib files from the archive", () => {
76
+ const tarball = createTarball(tmpDir, {
77
+ ".kntic/lib/orchestrator.py": "# orchestrator\n",
78
+ ".kntic/lib/skills/validator.py": "# validator\n",
79
+ ".kntic/MEMORY.MD": "# Memory\n",
80
+ ".kntic/adrs/ADR-001.md": "# ADR\n",
81
+ "README.md": "# Hello\n",
82
+ "package.json": '{"name":"test"}\n',
83
+ });
84
+
85
+ extractLibOnly(tarball, destDir);
86
+
87
+ // .kntic/lib files must be extracted
88
+ assert.equal(
89
+ fs.readFileSync(path.join(destDir, ".kntic", "lib", "orchestrator.py"), "utf8"),
90
+ "# orchestrator\n"
91
+ );
92
+ assert.equal(
93
+ fs.readFileSync(path.join(destDir, ".kntic", "lib", "skills", "validator.py"), "utf8"),
94
+ "# validator\n"
95
+ );
96
+
97
+ // Files outside .kntic/lib must NOT be extracted
98
+ assert.ok(
99
+ !fs.existsSync(path.join(destDir, ".kntic", "MEMORY.MD")),
100
+ "MEMORY.MD must not be extracted"
101
+ );
102
+ assert.ok(
103
+ !fs.existsSync(path.join(destDir, ".kntic", "adrs", "ADR-001.md")),
104
+ "ADR files must not be extracted"
105
+ );
106
+ assert.ok(
107
+ !fs.existsSync(path.join(destDir, "README.md")),
108
+ "README.md must not be extracted"
109
+ );
110
+ assert.ok(
111
+ !fs.existsSync(path.join(destDir, "package.json")),
112
+ "package.json must not be extracted"
113
+ );
114
+ });
115
+
116
+ it("replaces existing .kntic/lib content", () => {
117
+ // Pre-existing lib content
118
+ const libDir = path.join(destDir, ".kntic", "lib");
119
+ fs.mkdirSync(libDir, { recursive: true });
120
+ fs.writeFileSync(path.join(libDir, "old_file.py"), "# old\n");
121
+ fs.writeFileSync(path.join(libDir, "orchestrator.py"), "# old orchestrator\n");
122
+
123
+ const tarball = createTarball(tmpDir, {
124
+ ".kntic/lib/orchestrator.py": "# new orchestrator\n",
125
+ ".kntic/lib/new_file.py": "# new\n",
126
+ });
127
+
128
+ extractLibOnly(tarball, destDir);
129
+
130
+ // Old file must be gone
131
+ assert.ok(
132
+ !fs.existsSync(path.join(libDir, "old_file.py")),
133
+ "old_file.py must be removed"
134
+ );
135
+
136
+ // New files must be present
137
+ assert.equal(
138
+ fs.readFileSync(path.join(libDir, "orchestrator.py"), "utf8"),
139
+ "# new orchestrator\n"
140
+ );
141
+ assert.equal(
142
+ fs.readFileSync(path.join(libDir, "new_file.py"), "utf8"),
143
+ "# new\n"
144
+ );
145
+ });
146
+
147
+ it("preserves files outside .kntic/lib that already exist on disk", () => {
148
+ // Pre-existing files outside lib
149
+ const knticDir = path.join(destDir, ".kntic");
150
+ fs.mkdirSync(knticDir, { recursive: true });
151
+ fs.writeFileSync(path.join(knticDir, "MEMORY.MD"), "# My memory\n");
152
+ fs.writeFileSync(path.join(destDir, "README.md"), "# My readme\n");
153
+
154
+ const tarball = createTarball(tmpDir, {
155
+ ".kntic/lib/orchestrator.py": "# orchestrator\n",
156
+ ".kntic/MEMORY.MD": "# Archive memory\n",
157
+ "README.md": "# Archive readme\n",
158
+ });
159
+
160
+ extractLibOnly(tarball, destDir);
161
+
162
+ // Existing files outside lib must be untouched
163
+ assert.equal(
164
+ fs.readFileSync(path.join(knticDir, "MEMORY.MD"), "utf8"),
165
+ "# My memory\n",
166
+ "MEMORY.MD must not be overwritten"
167
+ );
168
+ assert.equal(
169
+ fs.readFileSync(path.join(destDir, "README.md"), "utf8"),
170
+ "# My readme\n",
171
+ "README.md must not be overwritten"
172
+ );
173
+
174
+ // Lib files must be extracted
175
+ assert.equal(
176
+ fs.readFileSync(path.join(knticDir, "lib", "orchestrator.py"), "utf8"),
177
+ "# orchestrator\n"
178
+ );
179
+ });
180
+
181
+ it("handles nested subdirectories in .kntic/lib", () => {
182
+ const tarball = createTarball(tmpDir, {
183
+ ".kntic/lib/skills/validator.py": "# validator\n",
184
+ ".kntic/lib/skills/navigation.py": "# navigation\n",
185
+ ".kntic/lib/agent_runner.py": "# agent_runner\n",
186
+ });
187
+
188
+ extractLibOnly(tarball, destDir);
189
+
190
+ assert.equal(
191
+ fs.readFileSync(path.join(destDir, ".kntic", "lib", "skills", "validator.py"), "utf8"),
192
+ "# validator\n"
193
+ );
194
+ assert.equal(
195
+ fs.readFileSync(path.join(destDir, ".kntic", "lib", "skills", "navigation.py"), "utf8"),
196
+ "# navigation\n"
197
+ );
198
+ assert.equal(
199
+ fs.readFileSync(path.join(destDir, ".kntic", "lib", "agent_runner.py"), "utf8"),
200
+ "# agent_runner\n"
201
+ );
202
+ });
203
+ });
204
+
205
+ describe("updateEnvVersion", () => {
206
+ let tmpDir;
207
+
208
+ beforeEach(() => {
209
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-test-env-"));
210
+ });
211
+
212
+ afterEach(() => {
213
+ fs.rmSync(tmpDir, { recursive: true, force: true });
214
+ });
215
+
216
+ it("creates the file with KNTIC_VERSION if it does not exist", () => {
217
+ const envPath = path.join(tmpDir, ".kntic.env");
218
+ updateEnvVersion("1.2.3", envPath);
219
+
220
+ assert.equal(fs.readFileSync(envPath, "utf8"), "KNTIC_VERSION=1.2.3\n");
221
+ });
222
+
223
+ it("appends KNTIC_VERSION if file exists but variable is missing", () => {
224
+ const envPath = path.join(tmpDir, ".kntic.env");
225
+ fs.writeFileSync(envPath, "UID=1000\nGID=1000\n");
226
+
227
+ updateEnvVersion("0.0.10", envPath);
228
+
229
+ const content = fs.readFileSync(envPath, "utf8");
230
+ assert.ok(content.includes("UID=1000"), "existing content must be preserved");
231
+ assert.ok(content.includes("GID=1000"), "existing content must be preserved");
232
+ assert.ok(content.includes("KNTIC_VERSION=0.0.10"), "version must be appended");
233
+ });
234
+
235
+ it("replaces existing KNTIC_VERSION in-place", () => {
236
+ const envPath = path.join(tmpDir, ".kntic.env");
237
+ fs.writeFileSync(envPath, "UID=1000\nKNTIC_VERSION=0.0.1\nGID=1000\n");
238
+
239
+ updateEnvVersion("2.0.0", envPath);
240
+
241
+ const content = fs.readFileSync(envPath, "utf8");
242
+ assert.ok(content.includes("KNTIC_VERSION=2.0.0"), "version must be updated");
243
+ assert.ok(!content.includes("KNTIC_VERSION=0.0.1"), "old version must be gone");
244
+ assert.ok(content.includes("UID=1000"), "other vars must be preserved");
245
+ assert.ok(content.includes("GID=1000"), "other vars must be preserved");
246
+ });
247
+
248
+ it("handles file without trailing newline when appending", () => {
249
+ const envPath = path.join(tmpDir, ".kntic.env");
250
+ fs.writeFileSync(envPath, "UID=1000");
251
+
252
+ updateEnvVersion("0.1.0", envPath);
253
+
254
+ const content = fs.readFileSync(envPath, "utf8");
255
+ assert.ok(content.includes("UID=1000"), "existing content must be preserved");
256
+ assert.ok(content.includes("KNTIC_VERSION=0.1.0"), "version must be appended");
257
+ // Ensure they're on separate lines
258
+ assert.ok(content.includes("UID=1000\nKNTIC_VERSION=0.1.0"), "must be on separate lines");
259
+ });
260
+ });
261
+
262
+ describe("extractVersion (update module)", () => {
263
+ it("parses version from artifact filename", () => {
264
+ assert.equal(extractVersion("kntic-bootstrap-v0.0.10.tar.gz"), "0.0.10");
265
+ assert.equal(extractVersion("kntic-bootstrap-v1.2.3.tar.gz"), "1.2.3");
266
+ });
267
+
268
+ it("throws on invalid filename", () => {
269
+ assert.throws(() => extractVersion("no-version-here.tar.gz"));
270
+ });
271
+ });
@@ -7,6 +7,7 @@ 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(" start Build and start KNTIC services via docker compose (uses kntic.yml + .kntic.env)");
10
+ console.log(" update Download the latest KNTIC bootstrap and update .kntic/lib only");
10
11
  console.log("");
11
12
  }
12
13