@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 +1 -1
- package/src/cli.js +4 -1
- package/src/commands/update.js +201 -2
- package/src/commands/update.test.js +341 -1
- package/src/commands/usage.js +3 -2
package/package.json
CHANGED
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 = {
|
|
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);
|
package/src/commands/update.js
CHANGED
|
@@ -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
|
+
});
|
package/src/commands/usage.js
CHANGED
|
@@ -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
|
|
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
|
|