@kntic/kntic 0.4.8 → 0.6.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 +6 -2
- package/src/commands/init.js +138 -1
- package/src/commands/init.test.js +134 -1
- package/src/commands/update.js +181 -0
- package/src/commands/update.test.js +272 -1
- package/src/commands/usage.js +3 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -10,7 +10,8 @@ const subcommand = args[0];
|
|
|
10
10
|
if (!subcommand || subcommand === "usage") {
|
|
11
11
|
commands.usage();
|
|
12
12
|
} else if (subcommand === "init") {
|
|
13
|
-
|
|
13
|
+
const initOpts = { interactive: args.includes("--interactive") || args.includes("-i") };
|
|
14
|
+
commands.init(initOpts).catch((err) => {
|
|
14
15
|
console.error(`Error: ${err.message}`);
|
|
15
16
|
process.exit(1);
|
|
16
17
|
});
|
|
@@ -30,7 +31,10 @@ if (!subcommand || subcommand === "usage") {
|
|
|
30
31
|
process.exit(1);
|
|
31
32
|
}
|
|
32
33
|
} else if (subcommand === "update") {
|
|
33
|
-
const updateOpts = {
|
|
34
|
+
const updateOpts = {
|
|
35
|
+
libOnly: args.includes("--lib-only"),
|
|
36
|
+
compose: args.includes("--compose"),
|
|
37
|
+
};
|
|
34
38
|
commands.update(updateOpts).catch((err) => {
|
|
35
39
|
console.error(`Error: ${err.message}`);
|
|
36
40
|
process.exit(1);
|
package/src/commands/init.js
CHANGED
|
@@ -6,6 +6,7 @@ const { execSync } = require("child_process");
|
|
|
6
6
|
const fs = require("fs");
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const os = require("os");
|
|
9
|
+
const readline = require("readline");
|
|
9
10
|
|
|
10
11
|
const BOOTSTRAP_ARTIFACT_URL =
|
|
11
12
|
"https://minio.kommune7.wien/public/kntic-bootstrap-latest.artifact";
|
|
@@ -155,7 +156,121 @@ function extractArchive(tarball, destDir) {
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Parse the git origin remote URL to extract host and repo path.
|
|
161
|
+
* Returns { host, repoPath } or null if not available.
|
|
162
|
+
*/
|
|
163
|
+
function parseGitRemote() {
|
|
164
|
+
try {
|
|
165
|
+
if (!fs.existsSync('.git')) return null;
|
|
166
|
+
const output = execSync('git remote -v', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
167
|
+
const fetchLine = output.split('\n').find(l => l.startsWith('origin') && l.includes('(fetch)'));
|
|
168
|
+
if (!fetchLine) return null;
|
|
169
|
+
const url = fetchLine.split(/\s+/)[1];
|
|
170
|
+
// SSH format: git@host:path
|
|
171
|
+
const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
172
|
+
if (sshMatch) return { host: sshMatch[1], repoPath: sshMatch[2] + '.git' };
|
|
173
|
+
// HTTPS with credentials: https://user:token@host/path.git
|
|
174
|
+
const httpsMatch = url.match(/^https?:\/\/(?:([^@]+)@)?([^/]+)\/(.+?)(?:\.git)?$/);
|
|
175
|
+
if (httpsMatch) {
|
|
176
|
+
const result = { host: httpsMatch[2], repoPath: httpsMatch[3] + '.git' };
|
|
177
|
+
// Extract glpat token from credentials (format: oauth2:glpat-xxx or just glpat-xxx)
|
|
178
|
+
if (httpsMatch[1]) {
|
|
179
|
+
const credParts = httpsMatch[1].split(':');
|
|
180
|
+
const token = credParts.find(p => p.startsWith('glpat-'));
|
|
181
|
+
if (token) result.gitlabToken = token;
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
} catch { return null; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Read an env file, replace values for given keys (or append if missing),
|
|
191
|
+
* and write back.
|
|
192
|
+
*/
|
|
193
|
+
function fillEnvValues(envPath, values) {
|
|
194
|
+
let content = fs.readFileSync(envPath, 'utf8');
|
|
195
|
+
for (const [key, val] of Object.entries(values)) {
|
|
196
|
+
const regex = new RegExp(`^(${key}=).*$`, 'm');
|
|
197
|
+
if (regex.test(content)) {
|
|
198
|
+
content = content.replace(regex, `$1${val}`);
|
|
199
|
+
} else {
|
|
200
|
+
// Key missing from file — append it
|
|
201
|
+
content = content.trimEnd() + `\n${key}=${val}\n`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
fs.writeFileSync(envPath, content);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Interactively prompt the user for each env variable in the file.
|
|
209
|
+
* Skips KNTIC_VERSION. Shows current value as default.
|
|
210
|
+
*/
|
|
211
|
+
function interactiveEnvSetup(envPath) {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
const content = fs.readFileSync(envPath, 'utf8');
|
|
214
|
+
const lines = content.split('\n');
|
|
215
|
+
|
|
216
|
+
// Collect KEY=VALUE entries to prompt for
|
|
217
|
+
const entries = [];
|
|
218
|
+
for (let i = 0; i < lines.length; i++) {
|
|
219
|
+
const match = lines[i].match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
220
|
+
if (match) {
|
|
221
|
+
const key = match[1];
|
|
222
|
+
if (key === 'KNTIC_VERSION') continue;
|
|
223
|
+
// Also skip GITLAB_TOKEN if already auto-filled with a real token
|
|
224
|
+
if (key === 'GITLAB_TOKEN' && match[2].startsWith('glpat-')) continue;
|
|
225
|
+
entries.push({ index: i, key, value: match[2] });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (entries.length === 0) {
|
|
230
|
+
resolve();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const rl = readline.createInterface({
|
|
235
|
+
input: process.stdin,
|
|
236
|
+
output: process.stdout,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const updatedValues = {};
|
|
240
|
+
let idx = 0;
|
|
241
|
+
|
|
242
|
+
function promptNext() {
|
|
243
|
+
if (idx >= entries.length) {
|
|
244
|
+
rl.close();
|
|
245
|
+
// Write updated values back
|
|
246
|
+
const newLines = [...lines];
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
const finalVal = updatedValues[entry.key] !== undefined
|
|
249
|
+
? updatedValues[entry.key]
|
|
250
|
+
: entry.value;
|
|
251
|
+
newLines[entry.index] = `${entry.key}=${finalVal}`;
|
|
252
|
+
}
|
|
253
|
+
fs.writeFileSync(envPath, newLines.join('\n'));
|
|
254
|
+
resolve();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const entry = entries[idx];
|
|
259
|
+
const displayVal = entry.value || '';
|
|
260
|
+
rl.question(` ${entry.key} [${displayVal}]: `, (answer) => {
|
|
261
|
+
if (answer.length > 0) {
|
|
262
|
+
updatedValues[entry.key] = answer;
|
|
263
|
+
}
|
|
264
|
+
idx++;
|
|
265
|
+
promptNext();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
promptNext();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function init(options = {}) {
|
|
159
274
|
// Resolve current version from the artifact metadata file
|
|
160
275
|
const artifactFilename = await fetchText(BOOTSTRAP_ARTIFACT_URL);
|
|
161
276
|
const version = extractVersion(artifactFilename);
|
|
@@ -174,9 +289,31 @@ async function init() {
|
|
|
174
289
|
// Clean up
|
|
175
290
|
fs.unlinkSync(tmpFile);
|
|
176
291
|
|
|
292
|
+
// Auto-detect git remote
|
|
293
|
+
const gitInfo = parseGitRemote();
|
|
294
|
+
|
|
295
|
+
// Auto-fill git values (both modes)
|
|
296
|
+
const autoValues = {};
|
|
297
|
+
if (gitInfo) {
|
|
298
|
+
autoValues.GIT_HOST = gitInfo.host;
|
|
299
|
+
autoValues.GIT_REPO_PATH = gitInfo.repoPath;
|
|
300
|
+
if (gitInfo.gitlabToken) {
|
|
301
|
+
autoValues.GITLAB_TOKEN = gitInfo.gitlabToken;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (Object.keys(autoValues).length > 0) {
|
|
305
|
+
fillEnvValues('.kntic.env', autoValues);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (options.interactive) {
|
|
309
|
+
await interactiveEnvSetup('.kntic.env');
|
|
310
|
+
}
|
|
311
|
+
|
|
177
312
|
console.log("Done. KNTIC project bootstrapped successfully.");
|
|
178
313
|
}
|
|
179
314
|
|
|
180
315
|
module.exports = init;
|
|
181
316
|
module.exports.extractArchive = extractArchive;
|
|
182
317
|
module.exports.extractVersion = extractVersion;
|
|
318
|
+
module.exports.parseGitRemote = parseGitRemote;
|
|
319
|
+
module.exports.fillEnvValues = fillEnvValues;
|
|
@@ -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 { extractArchive, extractVersion } = require("./init");
|
|
10
|
+
const { extractArchive, extractVersion, parseGitRemote, fillEnvValues } = require("./init");
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Helper — create a tar.gz archive in `tmpDir` containing the given files.
|
|
@@ -126,3 +126,136 @@ describe("extractVersion", () => {
|
|
|
126
126
|
assert.throws(() => extractVersion("no-version-here.tar.gz"));
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("parseGitRemote", () => {
|
|
131
|
+
let tmpDir;
|
|
132
|
+
let originalCwd;
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-git-test-"));
|
|
136
|
+
originalCwd = process.cwd();
|
|
137
|
+
process.chdir(tmpDir);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
process.chdir(originalCwd);
|
|
142
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("parses SSH remote", () => {
|
|
146
|
+
execSync("git init", { stdio: "pipe" });
|
|
147
|
+
execSync("git remote add origin git@gitlab.kommune7.wien:kntic-ai/orchestrator/control.git", { stdio: "pipe" });
|
|
148
|
+
const result = parseGitRemote();
|
|
149
|
+
assert.deepStrictEqual(result, {
|
|
150
|
+
host: "gitlab.kommune7.wien",
|
|
151
|
+
repoPath: "kntic-ai/orchestrator/control.git",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("parses HTTPS remote", () => {
|
|
156
|
+
execSync("git init", { stdio: "pipe" });
|
|
157
|
+
execSync("git remote add origin https://github.com/org/repo.git", { stdio: "pipe" });
|
|
158
|
+
const result = parseGitRemote();
|
|
159
|
+
assert.deepStrictEqual(result, {
|
|
160
|
+
host: "github.com",
|
|
161
|
+
repoPath: "org/repo.git",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns null when no .git dir", () => {
|
|
166
|
+
const result = parseGitRemote();
|
|
167
|
+
assert.equal(result, null);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("returns null when no origin remote", () => {
|
|
171
|
+
execSync("git init", { stdio: "pipe" });
|
|
172
|
+
const result = parseGitRemote();
|
|
173
|
+
assert.equal(result, null);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("parses HTTPS remote with embedded glpat token", () => {
|
|
177
|
+
execSync("git init", { stdio: "pipe" });
|
|
178
|
+
execSync("git remote add origin https://oauth2:glpat-abc123@gitlab.kommune7.wien/org/repo.git", { stdio: "pipe" });
|
|
179
|
+
const result = parseGitRemote();
|
|
180
|
+
assert.deepStrictEqual(result, {
|
|
181
|
+
host: "gitlab.kommune7.wien",
|
|
182
|
+
repoPath: "org/repo.git",
|
|
183
|
+
gitlabToken: "glpat-abc123",
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("HTTPS without credentials returns no gitlabToken", () => {
|
|
188
|
+
execSync("git init", { stdio: "pipe" });
|
|
189
|
+
execSync("git remote add origin https://github.com/org/repo.git", { stdio: "pipe" });
|
|
190
|
+
const result = parseGitRemote();
|
|
191
|
+
assert.equal(result.host, "github.com");
|
|
192
|
+
assert.equal(result.repoPath, "org/repo.git");
|
|
193
|
+
assert.equal(result.gitlabToken, undefined);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("HTTPS with non-glpat credentials returns no gitlabToken", () => {
|
|
197
|
+
execSync("git init", { stdio: "pipe" });
|
|
198
|
+
execSync("git remote add origin https://user:password@github.com/org/repo.git", { stdio: "pipe" });
|
|
199
|
+
const result = parseGitRemote();
|
|
200
|
+
assert.equal(result.host, "github.com");
|
|
201
|
+
assert.equal(result.repoPath, "org/repo.git");
|
|
202
|
+
assert.equal(result.gitlabToken, undefined);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("fillEnvValues", () => {
|
|
207
|
+
let tmpDir;
|
|
208
|
+
let envPath;
|
|
209
|
+
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kntic-env-test-"));
|
|
212
|
+
envPath = path.join(tmpDir, ".kntic.env");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterEach(() => {
|
|
216
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("replaces existing key", () => {
|
|
220
|
+
fs.writeFileSync(envPath, "GIT_HOST=\nGIT_REPO_PATH=\n");
|
|
221
|
+
fillEnvValues(envPath, { GIT_HOST: "gitlab.kommune7.wien" });
|
|
222
|
+
const content = fs.readFileSync(envPath, "utf8");
|
|
223
|
+
assert.ok(content.includes("GIT_HOST=gitlab.kommune7.wien"));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("appends missing key", () => {
|
|
227
|
+
fs.writeFileSync(envPath, "GIT_REPO_PATH=some/path.git\n");
|
|
228
|
+
fillEnvValues(envPath, { GIT_HOST: "gitlab.kommune7.wien" });
|
|
229
|
+
const content = fs.readFileSync(envPath, "utf8");
|
|
230
|
+
assert.ok(content.includes("GIT_HOST=gitlab.kommune7.wien"));
|
|
231
|
+
assert.ok(content.includes("GIT_REPO_PATH=some/path.git"));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("preserves other lines and comments", () => {
|
|
235
|
+
fs.writeFileSync(envPath, "# This is a comment\nAPI_KEY=abc123\nGIT_HOST=\n# Another comment\n");
|
|
236
|
+
fillEnvValues(envPath, { GIT_HOST: "example.com" });
|
|
237
|
+
const content = fs.readFileSync(envPath, "utf8");
|
|
238
|
+
assert.ok(content.includes("# This is a comment"));
|
|
239
|
+
assert.ok(content.includes("API_KEY=abc123"));
|
|
240
|
+
assert.ok(content.includes("GIT_HOST=example.com"));
|
|
241
|
+
assert.ok(content.includes("# Another comment"));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("handles multiple replacements", () => {
|
|
245
|
+
fs.writeFileSync(envPath, "GIT_HOST=\nGIT_REPO_PATH=\n");
|
|
246
|
+
fillEnvValues(envPath, {
|
|
247
|
+
GIT_HOST: "gitlab.kommune7.wien",
|
|
248
|
+
GIT_REPO_PATH: "kntic-ai/orchestrator/control.git",
|
|
249
|
+
});
|
|
250
|
+
const content = fs.readFileSync(envPath, "utf8");
|
|
251
|
+
assert.ok(content.includes("GIT_HOST=gitlab.kommune7.wien"));
|
|
252
|
+
assert.ok(content.includes("GIT_REPO_PATH=kntic-ai/orchestrator/control.git"));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("fills GITLAB_TOKEN from git remote", () => {
|
|
256
|
+
fs.writeFileSync(envPath, "GITLAB_TOKEN=***\nGIT_HOST=\n");
|
|
257
|
+
fillEnvValues(envPath, { GITLAB_TOKEN: "glpat-abc123" });
|
|
258
|
+
const content = fs.readFileSync(envPath, "utf8");
|
|
259
|
+
assert.ok(content.includes("GITLAB_TOKEN=glpat-abc123"));
|
|
260
|
+
});
|
|
261
|
+
});
|
package/src/commands/update.js
CHANGED
|
@@ -258,6 +258,153 @@ function extractUpdate(tarball, destDir) {
|
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
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
|
+
|
|
261
408
|
async function update(options = {}) {
|
|
262
409
|
const libOnly = options.libOnly || false;
|
|
263
410
|
|
|
@@ -278,6 +425,38 @@ async function update(options = {}) {
|
|
|
278
425
|
extractUpdate(tmpFile, ".");
|
|
279
426
|
}
|
|
280
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
|
+
|
|
281
460
|
// Update KNTIC_VERSION in .kntic.env
|
|
282
461
|
updateEnvVersion(version);
|
|
283
462
|
|
|
@@ -295,6 +474,8 @@ async function update(options = {}) {
|
|
|
295
474
|
module.exports = update;
|
|
296
475
|
module.exports.extractLibOnly = extractLibOnly;
|
|
297
476
|
module.exports.extractUpdate = extractUpdate;
|
|
477
|
+
module.exports.extractCompose = extractCompose;
|
|
298
478
|
module.exports.extractVersion = extractVersion;
|
|
299
479
|
module.exports.clearDirectory = clearDirectory;
|
|
300
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.
|
|
@@ -573,3 +573,274 @@ describe("extractVersion (update module)", () => {
|
|
|
573
573
|
assert.throws(() => extractVersion("no-version-here.tar.gz"));
|
|
574
574
|
});
|
|
575
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
|
@@ -6,11 +6,14 @@ function usage() {
|
|
|
6
6
|
console.log("Available commands:\n");
|
|
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
|
+
console.log(" --quick Default mode — non-interactive, auto-detects git remote");
|
|
10
|
+
console.log(" --interactive Walk through .kntic.env values interactively (-i)");
|
|
9
11
|
console.log(" start Build and start KNTIC services via docker compose (uses kntic.yml + .kntic.env)");
|
|
10
12
|
console.log(" --screen Run inside a GNU screen session");
|
|
11
13
|
console.log(" stop Stop KNTIC services via docker compose");
|
|
12
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");
|
|
13
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)");
|
|
14
17
|
console.log("");
|
|
15
18
|
}
|
|
16
19
|
|