iranti 0.2.21 → 0.2.23
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/README.md +24 -23
- package/dist/scripts/codex-setup.js +11 -5
- package/dist/scripts/iranti-cli.js +855 -120
- package/dist/scripts/iranti-mcp.js +1 -1
- package/dist/src/api/middleware/auth.d.ts +13 -0
- package/dist/src/api/middleware/auth.d.ts.map +1 -1
- package/dist/src/api/middleware/auth.js.map +1 -1
- package/dist/src/api/middleware/authorization.d.ts.map +1 -1
- package/dist/src/api/middleware/authorization.js +6 -3
- package/dist/src/api/middleware/authorization.js.map +1 -1
- package/dist/src/api/middleware/validation.d.ts +109 -0
- package/dist/src/api/middleware/validation.d.ts.map +1 -1
- package/dist/src/api/middleware/validation.js +97 -5
- package/dist/src/api/middleware/validation.js.map +1 -1
- package/dist/src/api/routes/knowledge.d.ts.map +1 -1
- package/dist/src/api/routes/knowledge.js +2 -1
- package/dist/src/api/routes/knowledge.js.map +1 -1
- package/dist/src/api/routes/memory.d.ts.map +1 -1
- package/dist/src/api/routes/memory.js +74 -11
- package/dist/src/api/routes/memory.js.map +1 -1
- package/dist/src/api/server.js +32 -3
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/attendant/AttendantInstance.d.ts +43 -0
- package/dist/src/attendant/AttendantInstance.d.ts.map +1 -1
- package/dist/src/attendant/AttendantInstance.js +87 -11
- package/dist/src/attendant/AttendantInstance.js.map +1 -1
- package/dist/src/attendant/index.d.ts +2 -1
- package/dist/src/attendant/index.d.ts.map +1 -1
- package/dist/src/attendant/index.js +4 -1
- package/dist/src/attendant/index.js.map +1 -1
- package/dist/src/lib/dockerCliParsing.d.ts +3 -0
- package/dist/src/lib/dockerCliParsing.d.ts.map +1 -0
- package/dist/src/lib/dockerCliParsing.js +23 -0
- package/dist/src/lib/dockerCliParsing.js.map +1 -0
- package/dist/src/lib/runtimeEnv.d.ts.map +1 -1
- package/dist/src/lib/runtimeEnv.js +27 -11
- package/dist/src/lib/runtimeEnv.js.map +1 -1
- package/dist/src/lib/runtimeLifecycle.d.ts +21 -0
- package/dist/src/lib/runtimeLifecycle.d.ts.map +1 -1
- package/dist/src/lib/runtimeLifecycle.js +120 -0
- package/dist/src/lib/runtimeLifecycle.js.map +1 -1
- package/dist/src/librarian/index.d.ts.map +1 -1
- package/dist/src/librarian/index.js +154 -116
- package/dist/src/librarian/index.js.map +1 -1
- package/dist/src/library/entity-resolution.d.ts.map +1 -1
- package/dist/src/library/entity-resolution.js +14 -4
- package/dist/src/library/entity-resolution.js.map +1 -1
- package/dist/src/library/locks.d.ts.map +1 -1
- package/dist/src/library/locks.js +35 -8
- package/dist/src/library/locks.js.map +1 -1
- package/dist/src/library/queries.d.ts +14 -20
- package/dist/src/library/queries.d.ts.map +1 -1
- package/dist/src/library/queries.js +90 -22
- package/dist/src/library/queries.js.map +1 -1
- package/dist/src/sdk/index.d.ts +39 -6
- package/dist/src/sdk/index.d.ts.map +1 -1
- package/dist/src/sdk/index.js +84 -13
- package/dist/src/sdk/index.js.map +1 -1
- package/dist/src/security/scopes.d.ts.map +1 -1
- package/dist/src/security/scopes.js +13 -1
- package/dist/src/security/scopes.js.map +1 -1
- package/package.json +2 -1
|
@@ -6,16 +6,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
9
|
-
const https_1 = __importDefault(require("https"));
|
|
10
9
|
const os_1 = __importDefault(require("os"));
|
|
11
10
|
const path_1 = __importDefault(require("path"));
|
|
12
11
|
const child_process_1 = require("child_process");
|
|
12
|
+
const https_1 = __importDefault(require("https"));
|
|
13
13
|
const promises_2 = __importDefault(require("readline/promises"));
|
|
14
14
|
const stream_1 = require("stream");
|
|
15
15
|
const net_1 = __importDefault(require("net"));
|
|
16
16
|
const client_1 = require("../src/library/client");
|
|
17
17
|
const apiKeys_1 = require("../src/security/apiKeys");
|
|
18
18
|
const escalationPaths_1 = require("../src/lib/escalationPaths");
|
|
19
|
+
const dockerCliParsing_1 = require("../src/lib/dockerCliParsing");
|
|
19
20
|
const runtimeEnv_1 = require("../src/lib/runtimeEnv");
|
|
20
21
|
const resolutionist_1 = require("../src/resolutionist");
|
|
21
22
|
const chat_1 = require("../src/chat");
|
|
@@ -55,6 +56,31 @@ const ANSI = {
|
|
|
55
56
|
};
|
|
56
57
|
let CLI_DEBUG = process.argv.includes('--debug') || process.env.IRANTI_DEBUG === '1';
|
|
57
58
|
let CLI_VERBOSE = CLI_DEBUG || process.argv.includes('--verbose') || process.env.IRANTI_VERBOSE === '1';
|
|
59
|
+
// H-7: Cleanup/rollback stack — LIFO handlers run on SIGINT/SIGTERM to undo partial multi-step operations
|
|
60
|
+
const _cleanupStack = [];
|
|
61
|
+
function pushCleanup(fn) {
|
|
62
|
+
_cleanupStack.push(fn);
|
|
63
|
+
}
|
|
64
|
+
function popCleanup() {
|
|
65
|
+
_cleanupStack.pop();
|
|
66
|
+
}
|
|
67
|
+
async function runCleanupStack() {
|
|
68
|
+
while (_cleanupStack.length > 0) {
|
|
69
|
+
const fn = _cleanupStack.pop();
|
|
70
|
+
try {
|
|
71
|
+
await fn();
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
75
|
+
process.stderr.write(`[cleanup] Error during rollback: ${msg}\n`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
80
|
+
process.on(sig, () => {
|
|
81
|
+
void runCleanupStack().finally(() => process.exit(130));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
58
84
|
function useColor() {
|
|
59
85
|
return Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
|
|
60
86
|
}
|
|
@@ -173,20 +199,172 @@ function defaultInstallRoot(scope) {
|
|
|
173
199
|
return path_1.default.join(os_1.default.homedir(), '.local', 'share', 'iranti');
|
|
174
200
|
}
|
|
175
201
|
function resolveInstallRoot(args, scope) {
|
|
202
|
+
return resolveInstallRootDetails(args, scope).root;
|
|
203
|
+
}
|
|
204
|
+
function walkAncestorPaths(startDir) {
|
|
205
|
+
const dirs = [];
|
|
206
|
+
let current = path_1.default.resolve(startDir);
|
|
207
|
+
while (true) {
|
|
208
|
+
dirs.push(current);
|
|
209
|
+
const parent = path_1.default.dirname(current);
|
|
210
|
+
if (parent === current)
|
|
211
|
+
break;
|
|
212
|
+
current = parent;
|
|
213
|
+
}
|
|
214
|
+
return dirs;
|
|
215
|
+
}
|
|
216
|
+
function findClosestAncestorFile(startDir, fileName) {
|
|
217
|
+
for (const dir of walkAncestorPaths(startDir)) {
|
|
218
|
+
const candidate = path_1.default.join(dir, fileName);
|
|
219
|
+
if (fs_1.default.existsSync(candidate) && fs_1.default.statSync(candidate).isFile()) {
|
|
220
|
+
return candidate;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
function findClosestAncestorRuntimeRoot(startDir) {
|
|
226
|
+
for (const dir of walkAncestorPaths(startDir)) {
|
|
227
|
+
for (const runtimeDirName of ['.iranti-runtime', '.iranti']) {
|
|
228
|
+
const candidate = path_1.default.join(dir, runtimeDirName);
|
|
229
|
+
if (fs_1.default.existsSync(candidate) && fs_1.default.statSync(candidate).isDirectory()) {
|
|
230
|
+
return path_1.default.resolve(candidate);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
function resolveInstallRootDetails(args, scope) {
|
|
176
237
|
const explicit = getFlag(args, 'root') ?? process.env.IRANTI_HOME;
|
|
177
|
-
if (explicit)
|
|
178
|
-
return
|
|
238
|
+
if (explicit) {
|
|
239
|
+
return {
|
|
240
|
+
root: path_1.default.resolve(explicit),
|
|
241
|
+
source: getFlag(args, 'root') ? 'flag' : 'env',
|
|
242
|
+
userRoot: defaultInstallRoot('user'),
|
|
243
|
+
systemRoot: defaultInstallRoot('system'),
|
|
244
|
+
installMetaPath: path_1.default.join(path_1.default.resolve(explicit), 'install.json'),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
179
247
|
const userRoot = defaultInstallRoot('user');
|
|
180
248
|
const systemRoot = defaultInstallRoot('system');
|
|
181
249
|
const userMeta = path_1.default.join(userRoot, 'install.json');
|
|
182
250
|
const systemMeta = path_1.default.join(systemRoot, 'install.json');
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
251
|
+
const cwd = process.cwd();
|
|
252
|
+
const projectBindingFile = findClosestAncestorFile(cwd, '.env.iranti');
|
|
253
|
+
const localRuntimeRoot = findClosestAncestorRuntimeRoot(cwd);
|
|
254
|
+
if (scope === 'system') {
|
|
255
|
+
return {
|
|
256
|
+
root: systemRoot,
|
|
257
|
+
source: 'default-system',
|
|
258
|
+
userRoot,
|
|
259
|
+
systemRoot,
|
|
260
|
+
installMetaPath: systemMeta,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (projectBindingFile && fs_1.default.existsSync(projectBindingFile)) {
|
|
264
|
+
try {
|
|
265
|
+
const raw = fs_1.default.readFileSync(projectBindingFile, 'utf-8');
|
|
266
|
+
const match = raw.match(/^\s*IRANTI_INSTANCE_ENV\s*=\s*(.+)\s*$/m);
|
|
267
|
+
const value = match?.[1]?.trim().replace(/^['"]|['"]$/g, '');
|
|
268
|
+
const boundRoot = inferRuntimeRootFromInstanceEnv(value);
|
|
269
|
+
if (boundRoot && fs_1.default.existsSync(boundRoot)) {
|
|
270
|
+
return {
|
|
271
|
+
root: boundRoot,
|
|
272
|
+
source: 'project-binding',
|
|
273
|
+
userRoot,
|
|
274
|
+
systemRoot,
|
|
275
|
+
installMetaPath: path_1.default.join(boundRoot, 'install.json'),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Fall through to other resolution strategies.
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (localRuntimeRoot) {
|
|
284
|
+
return {
|
|
285
|
+
root: localRuntimeRoot,
|
|
286
|
+
source: 'cwd-runtime',
|
|
287
|
+
userRoot,
|
|
288
|
+
systemRoot,
|
|
289
|
+
installMetaPath: path_1.default.join(localRuntimeRoot, 'install.json'),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (fs_1.default.existsSync(userMeta)) {
|
|
293
|
+
return {
|
|
294
|
+
root: userRoot,
|
|
295
|
+
source: 'user-install-meta',
|
|
296
|
+
userRoot,
|
|
297
|
+
systemRoot,
|
|
298
|
+
installMetaPath: userMeta,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (fs_1.default.existsSync(systemMeta)) {
|
|
302
|
+
return {
|
|
303
|
+
root: systemRoot,
|
|
304
|
+
source: 'system-install-meta',
|
|
305
|
+
userRoot,
|
|
306
|
+
systemRoot,
|
|
307
|
+
installMetaPath: systemMeta,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
root: userRoot,
|
|
312
|
+
source: 'default-user',
|
|
313
|
+
userRoot,
|
|
314
|
+
systemRoot,
|
|
315
|
+
installMetaPath: userMeta,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function describeRuntimeRootSource(source) {
|
|
319
|
+
switch (source) {
|
|
320
|
+
case 'flag':
|
|
321
|
+
return '--root flag';
|
|
322
|
+
case 'env':
|
|
323
|
+
return 'IRANTI_HOME';
|
|
324
|
+
case 'project-binding':
|
|
325
|
+
return 'project binding';
|
|
326
|
+
case 'cwd-runtime':
|
|
327
|
+
return 'cwd runtime root';
|
|
328
|
+
case 'user-install-meta':
|
|
329
|
+
return 'user install metadata';
|
|
330
|
+
case 'system-install-meta':
|
|
331
|
+
return 'system install metadata';
|
|
332
|
+
case 'default-system':
|
|
333
|
+
return 'system default';
|
|
334
|
+
case 'default-user':
|
|
335
|
+
default:
|
|
336
|
+
return 'user default';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function inferRuntimeRootFromInstanceEnv(instanceEnvFile) {
|
|
340
|
+
if (!instanceEnvFile)
|
|
341
|
+
return null;
|
|
342
|
+
const normalized = path_1.default.resolve(instanceEnvFile);
|
|
343
|
+
if (path_1.default.basename(normalized).toLowerCase() !== '.env')
|
|
344
|
+
return null;
|
|
345
|
+
const instanceDir = path_1.default.dirname(normalized);
|
|
346
|
+
const instancesDir = path_1.default.dirname(instanceDir);
|
|
347
|
+
if (path_1.default.basename(instancesDir).toLowerCase() !== 'instances')
|
|
348
|
+
return null;
|
|
349
|
+
return path_1.default.dirname(instancesDir);
|
|
350
|
+
}
|
|
351
|
+
async function inspectProjectBinding(projectEnvFile) {
|
|
352
|
+
try {
|
|
353
|
+
const env = await readEnvFile(projectEnvFile);
|
|
354
|
+
const instanceEnvFile = env.IRANTI_INSTANCE_ENV?.trim() || null;
|
|
355
|
+
return {
|
|
356
|
+
bindingFile: projectEnvFile,
|
|
357
|
+
instanceEnvFile,
|
|
358
|
+
runtimeRoot: inferRuntimeRootFromInstanceEnv(instanceEnvFile ?? undefined),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return {
|
|
363
|
+
bindingFile: projectEnvFile,
|
|
364
|
+
instanceEnvFile: null,
|
|
365
|
+
runtimeRoot: null,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
190
368
|
}
|
|
191
369
|
function getPackageVersion() {
|
|
192
370
|
const pkgPath = path_1.default.join(packageRoot(), 'package.json');
|
|
@@ -345,10 +523,24 @@ async function writeJson(filePath, value) {
|
|
|
345
523
|
await promises_1.default.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
|
|
346
524
|
}
|
|
347
525
|
async function writeText(filePath, content) {
|
|
348
|
-
|
|
526
|
+
// Atomic write: write to temp file then rename to avoid partial writes on crash
|
|
527
|
+
const tmpPath = `${filePath}.tmp${process.pid}`;
|
|
528
|
+
try {
|
|
529
|
+
await promises_1.default.writeFile(tmpPath, content, { encoding: 'utf-8', flag: 'w' });
|
|
530
|
+
await promises_1.default.rename(tmpPath, filePath);
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
await promises_1.default.unlink(tmpPath).catch(() => undefined);
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
349
536
|
}
|
|
537
|
+
const MAX_ENV_FILE_BYTES = 1048576; // 1 MiB
|
|
350
538
|
async function readEnvFile(filePath) {
|
|
351
539
|
const out = {};
|
|
540
|
+
const stat = await promises_1.default.stat(filePath).catch(() => null);
|
|
541
|
+
if (stat && stat.size > MAX_ENV_FILE_BYTES) {
|
|
542
|
+
throw new Error(`Env file too large (${stat.size} bytes): ${filePath}. Maximum is ${MAX_ENV_FILE_BYTES} bytes.`);
|
|
543
|
+
}
|
|
352
544
|
const raw = await promises_1.default.readFile(filePath, 'utf-8');
|
|
353
545
|
for (const line of raw.split(/\r?\n/)) {
|
|
354
546
|
const trimmed = line.trim();
|
|
@@ -448,9 +640,8 @@ async function upsertEnvFile(filePath, updates) {
|
|
|
448
640
|
}
|
|
449
641
|
const finalLines = nextLines
|
|
450
642
|
.join('\n')
|
|
451
|
-
.replace(
|
|
452
|
-
.
|
|
453
|
-
.trimEnd();
|
|
643
|
+
.replace(/^\n+/, '') // strip leading blank lines only
|
|
644
|
+
.trimEnd(); // strip trailing whitespace only — preserving internal blank line groups
|
|
454
645
|
await writeText(filePath, `${finalLines}\n`);
|
|
455
646
|
}
|
|
456
647
|
function redactSecret(value) {
|
|
@@ -469,44 +660,169 @@ function instancePaths(root, name) {
|
|
|
469
660
|
runtimeFile: path_1.default.join(instanceDir, 'runtime.json'),
|
|
470
661
|
};
|
|
471
662
|
}
|
|
472
|
-
|
|
663
|
+
function instanceRepairNextSteps(name) {
|
|
664
|
+
return [
|
|
665
|
+
`Run \`iranti configure instance ${name} --interactive\` to repair the instance files.`,
|
|
666
|
+
'Run `iranti status --json` to inspect the current config classification.',
|
|
667
|
+
];
|
|
668
|
+
}
|
|
669
|
+
async function loadInstanceEnv(root, name, options = {}) {
|
|
473
670
|
const paths = instancePaths(root, name);
|
|
474
|
-
|
|
671
|
+
const config = await inspectInstanceConfig(root, name);
|
|
672
|
+
if (!config.state.metaPresent && !config.state.envPresent) {
|
|
475
673
|
throw cliError('IRANTI_INSTANCE_NOT_FOUND', `Instance '${name}' not found at ${paths.instanceDir}`, [
|
|
476
674
|
'Run `iranti instance list` to see known instances.',
|
|
477
675
|
`Run \`iranti setup\` or \`iranti instance create ${name}\` if this instance does not exist yet.`,
|
|
478
676
|
], { instance: name, root, instanceDir: paths.instanceDir });
|
|
479
677
|
}
|
|
678
|
+
if (!options.allowRepair && config.classification !== 'complete') {
|
|
679
|
+
throw cliError(config.classification === 'partial' ? 'IRANTI_INSTANCE_INCOMPLETE' : 'IRANTI_INSTANCE_INVALID', `Instance '${name}' is ${config.classification}: ${config.detail}.`, instanceRepairNextSteps(name), {
|
|
680
|
+
instance: name,
|
|
681
|
+
root,
|
|
682
|
+
instanceDir: paths.instanceDir,
|
|
683
|
+
config: config.classification,
|
|
684
|
+
metaFile: paths.metaFile,
|
|
685
|
+
envFile: paths.envFile,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
let env = {};
|
|
689
|
+
if (config.state.envPresent && config.state.envReadable) {
|
|
690
|
+
env = await readEnvFile(paths.envFile);
|
|
691
|
+
}
|
|
692
|
+
else if (!options.allowRepair) {
|
|
693
|
+
throw cliError('IRANTI_INSTANCE_ENV_UNREADABLE', `Instance '${name}' env file is missing or unreadable: ${paths.envFile}`, instanceRepairNextSteps(name), { instance: name, root, envFile: paths.envFile, config: config.classification });
|
|
694
|
+
}
|
|
480
695
|
debugLog('Loaded instance env target.', { instance: name, envFile: paths.envFile });
|
|
481
696
|
return {
|
|
482
697
|
...paths,
|
|
483
|
-
env
|
|
698
|
+
env,
|
|
699
|
+
config,
|
|
484
700
|
};
|
|
485
701
|
}
|
|
486
|
-
async function
|
|
487
|
-
const {
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
702
|
+
async function inspectInstanceConfig(root, name) {
|
|
703
|
+
const { instanceDir, envFile, metaFile } = instancePaths(root, name);
|
|
704
|
+
const metaPresent = fs_1.default.existsSync(metaFile);
|
|
705
|
+
const envPresent = fs_1.default.existsSync(envFile);
|
|
706
|
+
let metaReadable = false;
|
|
707
|
+
let envReadable = false;
|
|
708
|
+
const ownershipIssues = [];
|
|
709
|
+
if (metaPresent) {
|
|
710
|
+
try {
|
|
711
|
+
const raw = await promises_1.default.readFile(metaFile, 'utf8');
|
|
712
|
+
const parsed = JSON.parse(raw);
|
|
713
|
+
metaReadable = typeof parsed.name === 'string' && parsed.name.trim().length > 0;
|
|
714
|
+
if (metaReadable) {
|
|
715
|
+
if (parsed.name?.trim() !== name) {
|
|
716
|
+
ownershipIssues.push(`instance.json name is ${parsed.name}`);
|
|
717
|
+
}
|
|
718
|
+
if (typeof parsed.instanceDir === 'string' && path_1.default.resolve(parsed.instanceDir) !== path_1.default.resolve(instanceDir)) {
|
|
719
|
+
ownershipIssues.push(`instance.json points to ${parsed.instanceDir}`);
|
|
720
|
+
}
|
|
721
|
+
if (typeof parsed.envFile === 'string' && path_1.default.resolve(parsed.envFile) !== path_1.default.resolve(envFile)) {
|
|
722
|
+
ownershipIssues.push(`instance.json envFile points to ${parsed.envFile}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
metaReadable = false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (envPresent) {
|
|
731
|
+
try {
|
|
732
|
+
await readEnvFile(envFile);
|
|
733
|
+
envReadable = true;
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
envReadable = false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
let classification;
|
|
740
|
+
let detail;
|
|
741
|
+
if (ownershipIssues.length > 0) {
|
|
742
|
+
classification = 'invalid';
|
|
743
|
+
detail = ownershipIssues.join('; ');
|
|
744
|
+
}
|
|
745
|
+
else if (metaPresent && envPresent && metaReadable && envReadable) {
|
|
746
|
+
classification = 'complete';
|
|
747
|
+
detail = 'instance metadata and env are present';
|
|
748
|
+
}
|
|
749
|
+
else if ((metaPresent && !metaReadable) || (envPresent && !envReadable)) {
|
|
750
|
+
classification = 'invalid';
|
|
751
|
+
detail = [
|
|
752
|
+
!metaReadable && metaPresent ? 'instance metadata unreadable' : null,
|
|
753
|
+
!envReadable && envPresent ? 'env unreadable' : null,
|
|
754
|
+
].filter((value) => Boolean(value)).join('; ');
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
classification = 'partial';
|
|
758
|
+
detail = [
|
|
759
|
+
!metaPresent ? 'missing instance.json' : null,
|
|
760
|
+
!envPresent ? 'missing .env' : null,
|
|
761
|
+
].filter((value) => Boolean(value)).join('; ') || 'instance directory incomplete';
|
|
491
762
|
}
|
|
492
|
-
const running = (0, runtimeLifecycle_1.isPidRunning)(state.pid);
|
|
493
763
|
return {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
764
|
+
classification,
|
|
765
|
+
detail,
|
|
766
|
+
metaFile,
|
|
767
|
+
envFile,
|
|
768
|
+
state: {
|
|
769
|
+
metaPresent,
|
|
770
|
+
envPresent,
|
|
771
|
+
metaReadable,
|
|
772
|
+
envReadable,
|
|
773
|
+
},
|
|
497
774
|
};
|
|
498
775
|
}
|
|
776
|
+
async function readInstanceRuntimeSummary(root, name) {
|
|
777
|
+
const { runtimeFile } = instancePaths(root, name);
|
|
778
|
+
return (0, runtimeLifecycle_1.inspectRuntimeState)(runtimeFile);
|
|
779
|
+
}
|
|
499
780
|
function describeInstanceRuntime(summary) {
|
|
500
|
-
|
|
501
|
-
|
|
781
|
+
switch (summary.classification) {
|
|
782
|
+
case 'running':
|
|
783
|
+
return `${okLabel('RUNNING')} ${summary.detail}`;
|
|
784
|
+
case 'unhealthy':
|
|
785
|
+
return `${failLabel('UNHEALTHY')} ${summary.detail}`;
|
|
786
|
+
case 'stale':
|
|
787
|
+
return `${warnLabel('STALE')} ${summary.detail}`;
|
|
788
|
+
case 'stopped':
|
|
789
|
+
return `${warnLabel('STOPPED')} ${summary.detail}`;
|
|
790
|
+
case 'invalid':
|
|
791
|
+
return `${failLabel('INVALID')} ${summary.detail}`;
|
|
792
|
+
case 'missing':
|
|
793
|
+
default:
|
|
794
|
+
return `${warnLabel('STOPPED')} ${summary.detail}`;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function describeInstanceConfig(summary) {
|
|
798
|
+
switch (summary.classification) {
|
|
799
|
+
case 'complete':
|
|
800
|
+
return `${okLabel('COMPLETE')} ${summary.detail}`;
|
|
801
|
+
case 'partial':
|
|
802
|
+
return `${warnLabel('PARTIAL')} ${summary.detail}`;
|
|
803
|
+
case 'invalid':
|
|
804
|
+
default:
|
|
805
|
+
return `${failLabel('INVALID')} ${summary.detail}`;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
function buildInstanceRepairHints(name, config, runtime) {
|
|
809
|
+
const hints = [];
|
|
810
|
+
if (config.classification === 'partial') {
|
|
811
|
+
hints.push(`Run \`iranti configure instance ${name} --interactive\` to finish the missing instance files.`);
|
|
502
812
|
}
|
|
503
|
-
if (
|
|
504
|
-
|
|
813
|
+
if (config.classification === 'invalid') {
|
|
814
|
+
hints.push(`Run \`iranti configure instance ${name} --interactive\` to repair invalid instance metadata or env values.`);
|
|
505
815
|
}
|
|
506
|
-
if (
|
|
507
|
-
|
|
816
|
+
if (runtime.classification === 'stale') {
|
|
817
|
+
hints.push(`Run \`iranti instance restart ${name}\` if this instance should still be running.`);
|
|
508
818
|
}
|
|
509
|
-
|
|
819
|
+
if (runtime.classification === 'unhealthy') {
|
|
820
|
+
hints.push(`Run \`iranti doctor --instance ${name}\` and inspect ${runtime.state?.healthUrl ?? `http://localhost:${runtime.state?.port ?? 3001}/health`} before restarting.`);
|
|
821
|
+
}
|
|
822
|
+
if (runtime.classification === 'invalid') {
|
|
823
|
+
hints.push(`Inspect ${runtime.state?.runtimeFile ?? `instances/${name}/runtime.json`} for copied or foreign runtime metadata.`);
|
|
824
|
+
}
|
|
825
|
+
return Array.from(new Set(hints));
|
|
510
826
|
}
|
|
511
827
|
async function startInstanceRuntime(name, instanceDir, envFile, runtimeFile) {
|
|
512
828
|
process.env.IRANTI_INSTANCE_NAME = name;
|
|
@@ -676,8 +992,9 @@ async function withPromptSession(run) {
|
|
|
676
992
|
secret: async (prompt, currentValue) => {
|
|
677
993
|
const placeholder = currentValue ? `${redactSecret(currentValue)} (enter new value to replace)` : 'leave blank to skip';
|
|
678
994
|
const suffix = placeholder ? ` [${placeholder}]` : '';
|
|
995
|
+
process.stdout.write(`${prompt}${suffix}: `);
|
|
679
996
|
muted = true;
|
|
680
|
-
const answer = (await rl.question(
|
|
997
|
+
const answer = (await rl.question('')).trim();
|
|
681
998
|
muted = false;
|
|
682
999
|
process.stdout.write('\n');
|
|
683
1000
|
if (!answer || answer === placeholder)
|
|
@@ -689,8 +1006,9 @@ async function withPromptSession(run) {
|
|
|
689
1006
|
secretRequired: async (prompt, currentValue) => {
|
|
690
1007
|
const placeholder = currentValue ? `${redactSecret(currentValue)} (enter new value to replace)` : 'required';
|
|
691
1008
|
const suffix = placeholder ? ` [${placeholder}]` : '';
|
|
1009
|
+
process.stdout.write(`${prompt}${suffix}: `);
|
|
692
1010
|
muted = true;
|
|
693
|
-
const answer = (await rl.question(
|
|
1011
|
+
const answer = (await rl.question('')).trim();
|
|
694
1012
|
muted = false;
|
|
695
1013
|
process.stdout.write('\n');
|
|
696
1014
|
if (!answer || answer === placeholder)
|
|
@@ -709,13 +1027,30 @@ function detectPlaceholder(value) {
|
|
|
709
1027
|
if (!value)
|
|
710
1028
|
return true;
|
|
711
1029
|
const normalized = value.trim().toLowerCase();
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1030
|
+
if (normalized.length === 0)
|
|
1031
|
+
return true;
|
|
1032
|
+
const exactWeakValues = new Set([
|
|
1033
|
+
'changeme',
|
|
1034
|
+
'placeholder',
|
|
1035
|
+
'example',
|
|
1036
|
+
'todo',
|
|
1037
|
+
'fixme',
|
|
1038
|
+
'none',
|
|
1039
|
+
'null',
|
|
1040
|
+
'undefined',
|
|
1041
|
+
]);
|
|
1042
|
+
if (exactWeakValues.has(normalized))
|
|
1043
|
+
return true;
|
|
1044
|
+
const weakFragments = [
|
|
1045
|
+
'yourpassword',
|
|
1046
|
+
'replace_me',
|
|
1047
|
+
'your_secret',
|
|
1048
|
+
'your_key_here',
|
|
1049
|
+
'your_api_key',
|
|
1050
|
+
'insert_key_here',
|
|
1051
|
+
'add_your_key',
|
|
1052
|
+
];
|
|
1053
|
+
return weakFragments.some((p) => normalized.includes(p));
|
|
719
1054
|
}
|
|
720
1055
|
function quoteSqlLiteral(value) {
|
|
721
1056
|
return `'${value.replace(/'/g, "''")}'`;
|
|
@@ -750,6 +1085,9 @@ function isLocalPostgresHost(hostname) {
|
|
|
750
1085
|
}
|
|
751
1086
|
function sanitizeIdentifier(input, fallback) {
|
|
752
1087
|
const value = input.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
1088
|
+
if (!value && input.trim()) {
|
|
1089
|
+
verboseLog(`sanitizeIdentifier: input "${input}" normalized to empty — using fallback "${fallback}"`);
|
|
1090
|
+
}
|
|
753
1091
|
return value || fallback;
|
|
754
1092
|
}
|
|
755
1093
|
function projectAgentDefault(projectPath) {
|
|
@@ -840,6 +1178,18 @@ async function promptRequiredSecret(session, prompt, currentValue) {
|
|
|
840
1178
|
console.log(`${warnLabel()} ${prompt} is required.`);
|
|
841
1179
|
}
|
|
842
1180
|
}
|
|
1181
|
+
async function promptSecretWithDefault(session, prompt, defaultValue) {
|
|
1182
|
+
while (true) {
|
|
1183
|
+
const value = (await session.secret(`${prompt} (blank uses local-dev default)`, undefined) ?? '').trim();
|
|
1184
|
+
if (!value) {
|
|
1185
|
+
console.log(`${infoLabel()} Using the local development default for ${prompt}.`);
|
|
1186
|
+
return defaultValue;
|
|
1187
|
+
}
|
|
1188
|
+
if (!detectPlaceholder(value))
|
|
1189
|
+
return value;
|
|
1190
|
+
console.log(`${warnLabel()} ${prompt} still looks like a placeholder. Enter a real value or leave it blank to use the local-dev default.`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
843
1193
|
function makeLegacyInstanceApiKey(instanceName) {
|
|
844
1194
|
const keyId = sanitizeIdentifier(`${instanceName}_${os_1.default.userInfo().username}`, 'iranti');
|
|
845
1195
|
return (0, apiKeys_1.formatApiKeyToken)(keyId, (0, apiKeys_1.generateApiKeySecret)());
|
|
@@ -883,6 +1233,7 @@ async function ensureInstanceConfigured(root, name, config) {
|
|
|
883
1233
|
LLM_PROVIDER: config.provider,
|
|
884
1234
|
...config.providerKeys,
|
|
885
1235
|
});
|
|
1236
|
+
await syncInstanceMeta(root, name, config.port);
|
|
886
1237
|
return { envFile, instanceDir, created };
|
|
887
1238
|
}
|
|
888
1239
|
function makeIrantiMcpServerConfig() {
|
|
@@ -990,6 +1341,76 @@ function resolveAttendMessage(args) {
|
|
|
990
1341
|
return fromPositionals;
|
|
991
1342
|
throw new Error('Missing latest message. Usage: iranti attend [message] [--message <text>] [--context <text>] [--json]');
|
|
992
1343
|
}
|
|
1344
|
+
function parseDelimitedList(raw) {
|
|
1345
|
+
if (!raw?.trim())
|
|
1346
|
+
return [];
|
|
1347
|
+
const delimiter = raw.includes('||') ? '||' : ',';
|
|
1348
|
+
return raw
|
|
1349
|
+
.split(delimiter)
|
|
1350
|
+
.map((item) => item.trim())
|
|
1351
|
+
.filter(Boolean);
|
|
1352
|
+
}
|
|
1353
|
+
function resolveTaskEntity(args) {
|
|
1354
|
+
const entity = (args.subcommand ?? args.positionals[0] ?? getFlag(args, 'entity') ?? '').trim();
|
|
1355
|
+
if (!entity) {
|
|
1356
|
+
throw new Error('Missing task entity. Usage: iranti handoff task/<task_id> --next-step <text> [--json]');
|
|
1357
|
+
}
|
|
1358
|
+
if (!entity.includes('/')) {
|
|
1359
|
+
throw new Error('task entity must use entityType/entityId format.');
|
|
1360
|
+
}
|
|
1361
|
+
return entity;
|
|
1362
|
+
}
|
|
1363
|
+
function buildHandoffSummary(key, value) {
|
|
1364
|
+
switch (key) {
|
|
1365
|
+
case 'status': {
|
|
1366
|
+
const state = typeof value === 'object' && value && 'state' in value ? String(value.state) : 'updated';
|
|
1367
|
+
return `Shared task status is ${state}.`;
|
|
1368
|
+
}
|
|
1369
|
+
case 'current_owner': {
|
|
1370
|
+
const agentId = typeof value === 'object' && value && 'agentId' in value ? String(value.agentId) : 'unassigned';
|
|
1371
|
+
return `Current owner is ${agentId}.`;
|
|
1372
|
+
}
|
|
1373
|
+
case 'next_step': {
|
|
1374
|
+
const instruction = typeof value === 'object' && value && 'instruction' in value ? String(value.instruction) : 'Next step updated.';
|
|
1375
|
+
return truncateText(`Next step: ${instruction}`, 140);
|
|
1376
|
+
}
|
|
1377
|
+
case 'blockers': {
|
|
1378
|
+
const count = typeof value === 'object' && value && 'items' in value && Array.isArray(value.items)
|
|
1379
|
+
? value.items.length
|
|
1380
|
+
: 0;
|
|
1381
|
+
return count === 0 ? 'No blockers recorded.' : `${count} blocker${count === 1 ? '' : 's'} recorded for the shared task.`;
|
|
1382
|
+
}
|
|
1383
|
+
case 'artifacts': {
|
|
1384
|
+
const count = typeof value === 'object' && value && 'files' in value && Array.isArray(value.files)
|
|
1385
|
+
? value.files.length
|
|
1386
|
+
: 0;
|
|
1387
|
+
return count === 0 ? 'No artifacts recorded.' : `${count} artifact${count === 1 ? '' : 's'} recorded for the shared task.`;
|
|
1388
|
+
}
|
|
1389
|
+
case 'notes': {
|
|
1390
|
+
const text = typeof value === 'object' && value && 'text' in value ? String(value.text) : 'Shared handoff notes updated.';
|
|
1391
|
+
return truncateText(`Notes: ${text}`, 140);
|
|
1392
|
+
}
|
|
1393
|
+
case 'active_handoff_task': {
|
|
1394
|
+
const taskEntity = typeof value === 'object' && value && 'taskEntity' in value ? String(value.taskEntity) : 'task';
|
|
1395
|
+
return `Project now points to active handoff ${taskEntity}.`;
|
|
1396
|
+
}
|
|
1397
|
+
default:
|
|
1398
|
+
return 'Shared handoff state updated.';
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
function printHandoffResult(target, taskEntity, writes) {
|
|
1402
|
+
console.log(bold('Iranti handoff'));
|
|
1403
|
+
console.log(` agent ${target.agentId}`);
|
|
1404
|
+
console.log(` env source ${target.envSource}`);
|
|
1405
|
+
if (target.envFile)
|
|
1406
|
+
console.log(` env file ${target.envFile}`);
|
|
1407
|
+
console.log(` task entity ${taskEntity}`);
|
|
1408
|
+
console.log(` writes ${writes.length}`);
|
|
1409
|
+
console.log('');
|
|
1410
|
+
for (const write of writes) {
|
|
1411
|
+
console.log(`- ${write.entity} :: ${write.key} | ${write.summary}`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
993
1414
|
function printHandshakeResult(target, task, result) {
|
|
994
1415
|
console.log(bold('Iranti handshake'));
|
|
995
1416
|
console.log(` agent ${target.agentId}`);
|
|
@@ -1220,7 +1641,7 @@ function inspectDockerAvailability() {
|
|
|
1220
1641
|
detail: `Docker CLI is installed, but the daemon is not reachable. ${reason}`,
|
|
1221
1642
|
};
|
|
1222
1643
|
}
|
|
1223
|
-
async function isPortAvailable(port, host = '
|
|
1644
|
+
async function isPortAvailable(port, host = '0.0.0.0') {
|
|
1224
1645
|
return await new Promise((resolve) => {
|
|
1225
1646
|
const server = net_1.default.createServer();
|
|
1226
1647
|
server.unref();
|
|
@@ -1230,18 +1651,58 @@ async function isPortAvailable(port, host = '127.0.0.1') {
|
|
|
1230
1651
|
});
|
|
1231
1652
|
});
|
|
1232
1653
|
}
|
|
1233
|
-
|
|
1654
|
+
function listPublishedDockerHostPorts() {
|
|
1655
|
+
const docker = inspectDockerAvailability();
|
|
1656
|
+
if (!docker.daemonReachable)
|
|
1657
|
+
return new Set();
|
|
1658
|
+
const inspect = runCommandCapture('docker', ['ps', '--format', '{{.Ports}}']);
|
|
1659
|
+
if (inspect.status !== 0)
|
|
1660
|
+
return new Set();
|
|
1661
|
+
return (0, dockerCliParsing_1.parsePublishedDockerHostPorts)(inspect.stdout ?? '');
|
|
1662
|
+
}
|
|
1663
|
+
async function isPortUsable(port, host = '0.0.0.0', dockerPublishedPorts = new Set()) {
|
|
1664
|
+
if (dockerPublishedPorts.has(port))
|
|
1665
|
+
return false;
|
|
1666
|
+
return isPortAvailable(port, host);
|
|
1667
|
+
}
|
|
1668
|
+
async function findNextAvailablePort(start, host = '0.0.0.0', maxSteps = 50, dockerPublishedPorts = new Set()) {
|
|
1234
1669
|
for (let port = start; port < start + maxSteps; port += 1) {
|
|
1235
|
-
if (await
|
|
1670
|
+
if (await isPortUsable(port, host, dockerPublishedPorts)) {
|
|
1236
1671
|
return port;
|
|
1237
1672
|
}
|
|
1238
1673
|
}
|
|
1239
1674
|
throw new Error(`No available port found in range ${start}-${start + maxSteps - 1}.`);
|
|
1240
1675
|
}
|
|
1241
|
-
async function
|
|
1676
|
+
async function readAllInstancePorts(root) {
|
|
1677
|
+
const ports = new Set();
|
|
1678
|
+
const instancesDir = path_1.default.join(root, 'instances');
|
|
1679
|
+
if (!fs_1.default.existsSync(instancesDir))
|
|
1680
|
+
return ports;
|
|
1681
|
+
try {
|
|
1682
|
+
const entries = await promises_1.default.readdir(instancesDir, { withFileTypes: true });
|
|
1683
|
+
for (const entry of entries) {
|
|
1684
|
+
if (!entry.isDirectory())
|
|
1685
|
+
continue;
|
|
1686
|
+
const metaPath = path_1.default.join(instancesDir, entry.name, 'instance.json');
|
|
1687
|
+
try {
|
|
1688
|
+
const raw = await promises_1.default.readFile(metaPath, 'utf-8');
|
|
1689
|
+
const meta = JSON.parse(raw);
|
|
1690
|
+
const port = Number(meta.port);
|
|
1691
|
+
if (Number.isFinite(port) && port > 0)
|
|
1692
|
+
ports.add(port);
|
|
1693
|
+
}
|
|
1694
|
+
catch { /* ignore unreadable meta files */ }
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
catch { /* ignore unreadable instances dir */ }
|
|
1698
|
+
return ports;
|
|
1699
|
+
}
|
|
1700
|
+
async function chooseAvailablePort(session, promptText, preferredPort, allowOccupiedCurrent = false, reservedPorts = new Set()) {
|
|
1701
|
+
const dockerPublishedPorts = listPublishedDockerHostPorts();
|
|
1702
|
+
const allReserved = new Set([...dockerPublishedPorts, ...reservedPorts]);
|
|
1242
1703
|
let suggested = preferredPort;
|
|
1243
|
-
if (!allowOccupiedCurrent && !(await
|
|
1244
|
-
suggested = await findNextAvailablePort(preferredPort + 1);
|
|
1704
|
+
if (!allowOccupiedCurrent && !(await isPortUsable(preferredPort, '0.0.0.0', allReserved))) {
|
|
1705
|
+
suggested = await findNextAvailablePort(preferredPort + 1, '0.0.0.0', 50, allReserved);
|
|
1245
1706
|
console.log(`${warnLabel()} Port ${preferredPort} is already in use. A good next option is ${suggested}.`);
|
|
1246
1707
|
}
|
|
1247
1708
|
while (true) {
|
|
@@ -1254,14 +1715,66 @@ async function chooseAvailablePort(session, promptText, preferredPort, allowOccu
|
|
|
1254
1715
|
if (allowOccupiedCurrent && parsed === preferredPort) {
|
|
1255
1716
|
return parsed;
|
|
1256
1717
|
}
|
|
1257
|
-
if (await
|
|
1718
|
+
if (await isPortUsable(parsed, '0.0.0.0', allReserved)) {
|
|
1258
1719
|
return parsed;
|
|
1259
1720
|
}
|
|
1260
|
-
const next = await findNextAvailablePort(parsed + 1);
|
|
1721
|
+
const next = await findNextAvailablePort(parsed + 1, '0.0.0.0', 50, allReserved);
|
|
1261
1722
|
console.log(`${warnLabel()} Port ${parsed} is already in use. Try ${next} instead.`);
|
|
1262
1723
|
suggested = next;
|
|
1263
1724
|
}
|
|
1264
1725
|
}
|
|
1726
|
+
async function syncInstanceMeta(root, name, port) {
|
|
1727
|
+
const { instanceDir, envFile, metaFile } = instancePaths(root, name);
|
|
1728
|
+
const existingCreatedAt = fs_1.default.existsSync(metaFile)
|
|
1729
|
+
? await promises_1.default.readFile(metaFile, 'utf-8')
|
|
1730
|
+
.then((raw) => {
|
|
1731
|
+
try {
|
|
1732
|
+
const parsed = JSON.parse(raw);
|
|
1733
|
+
return typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined;
|
|
1734
|
+
}
|
|
1735
|
+
catch {
|
|
1736
|
+
return undefined;
|
|
1737
|
+
}
|
|
1738
|
+
})
|
|
1739
|
+
: undefined;
|
|
1740
|
+
const meta = {
|
|
1741
|
+
name,
|
|
1742
|
+
createdAt: existingCreatedAt ?? new Date().toISOString(),
|
|
1743
|
+
port,
|
|
1744
|
+
envFile,
|
|
1745
|
+
instanceDir,
|
|
1746
|
+
};
|
|
1747
|
+
await writeJson(metaFile, meta);
|
|
1748
|
+
}
|
|
1749
|
+
async function assertPortAssignable(root, port, currentInstanceName) {
|
|
1750
|
+
const reservedPorts = await readAllInstancePorts(root);
|
|
1751
|
+
let allowCurrentRunningPort = false;
|
|
1752
|
+
if (currentInstanceName) {
|
|
1753
|
+
const { envFile } = instancePaths(root, currentInstanceName);
|
|
1754
|
+
if (fs_1.default.existsSync(envFile)) {
|
|
1755
|
+
try {
|
|
1756
|
+
const env = await readEnvFile(envFile);
|
|
1757
|
+
const currentPort = Number.parseInt(env.IRANTI_PORT ?? '', 10);
|
|
1758
|
+
if (Number.isFinite(currentPort) && currentPort > 0) {
|
|
1759
|
+
reservedPorts.delete(currentPort);
|
|
1760
|
+
if (currentPort === port) {
|
|
1761
|
+
const runtime = await readInstanceRuntimeSummary(root, currentInstanceName);
|
|
1762
|
+
allowCurrentRunningPort = runtime.running && runtime.state?.port === port;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
catch {
|
|
1767
|
+
// Ignore unreadable current instance env and fall back to stricter validation.
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
if (reservedPorts.has(port)) {
|
|
1772
|
+
throw new Error(`Port ${port} is already assigned to another Iranti instance.`);
|
|
1773
|
+
}
|
|
1774
|
+
if (!allowCurrentRunningPort && !(await isPortUsable(port, '0.0.0.0', listPublishedDockerHostPorts()))) {
|
|
1775
|
+
throw new Error(`Port ${port} is already in use.`);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1265
1778
|
async function waitForTcpPort(host, port, timeoutMs) {
|
|
1266
1779
|
const deadline = Date.now() + timeoutMs;
|
|
1267
1780
|
while (Date.now() < deadline) {
|
|
@@ -1290,16 +1803,14 @@ async function runDockerPostgresContainer(options) {
|
|
|
1290
1803
|
if (!docker.daemonReachable) {
|
|
1291
1804
|
throw new Error(`Docker daemon is not reachable. Start Docker Desktop or Docker Engine, then retry. ${docker.detail}`);
|
|
1292
1805
|
}
|
|
1293
|
-
const inspect =
|
|
1294
|
-
? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', `docker ps -a --format "{{.Names}}"`], { encoding: 'utf8' })
|
|
1295
|
-
: (0, child_process_1.spawnSync)('docker', ['ps', '-a', '--format', '{{.Names}}'], { encoding: 'utf8' });
|
|
1806
|
+
const inspect = runCommandCapture('docker', ['ps', '-a', '--format', '{{.Names}}']);
|
|
1296
1807
|
if (inspect.status !== 0) {
|
|
1297
1808
|
throw new Error(`Failed to inspect Docker containers. ${(inspect.stderr ?? inspect.stdout ?? '').trim() || 'docker ps returned a non-zero exit code.'}`);
|
|
1298
1809
|
}
|
|
1299
|
-
const names = (inspect.stdout ?? '')
|
|
1810
|
+
const names = (0, dockerCliParsing_1.parseDockerContainerNames)(inspect.stdout ?? '');
|
|
1300
1811
|
if (names.includes(options.containerName)) {
|
|
1301
1812
|
const start = process.platform === 'win32'
|
|
1302
|
-
? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c',
|
|
1813
|
+
? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', ['docker', 'start', options.containerName].map(quoteForCmd).join(' ')], { stdio: 'inherit' })
|
|
1303
1814
|
: (0, child_process_1.spawnSync)('docker', ['start', options.containerName], { stdio: 'inherit' });
|
|
1304
1815
|
if (start.status !== 0) {
|
|
1305
1816
|
throw new Error(`Failed to start existing Docker container '${options.containerName}'.`);
|
|
@@ -1800,9 +2311,12 @@ function printDependencyChecks(checks) {
|
|
|
1800
2311
|
function quoteForCmd(arg) {
|
|
1801
2312
|
if (arg.length === 0)
|
|
1802
2313
|
return '""';
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
2314
|
+
// Escape % to prevent CMD variable expansion (%VAR%)
|
|
2315
|
+
const pctEscaped = arg.replace(/%/g, '%%');
|
|
2316
|
+
if (!/[ \t"&()<>|^%!]/.test(arg))
|
|
2317
|
+
return pctEscaped;
|
|
2318
|
+
// Use "" for inner double quotes (CMD convention, not Unix \")
|
|
2319
|
+
return `"${pctEscaped.replace(/"/g, '""')}"`;
|
|
1806
2320
|
}
|
|
1807
2321
|
function runCommandCapture(executable, args, cwd, extraEnv) {
|
|
1808
2322
|
verboseLog('Running subprocess (capture).', {
|
|
@@ -1895,7 +2409,11 @@ function spawnDetachedCli(args, cwd) {
|
|
|
1895
2409
|
env: process.env,
|
|
1896
2410
|
});
|
|
1897
2411
|
child.unref();
|
|
1898
|
-
|
|
2412
|
+
const pid = child.pid;
|
|
2413
|
+
if (!pid) {
|
|
2414
|
+
throw new Error(`Failed to spawn detached CLI process (executable: ${invocation.executable}). The process did not start.`);
|
|
2415
|
+
}
|
|
2416
|
+
return pid;
|
|
1899
2417
|
}
|
|
1900
2418
|
async function restartInstanceRuntime(args, instanceName, scope, root) {
|
|
1901
2419
|
const runtimeBefore = await readInstanceRuntimeSummary(root, instanceName);
|
|
@@ -2103,14 +2621,20 @@ function readJsonFile(filePath) {
|
|
|
2103
2621
|
return null;
|
|
2104
2622
|
}
|
|
2105
2623
|
}
|
|
2106
|
-
function httpsJson(url, headers = {}) {
|
|
2624
|
+
function httpsJson(url, headers = {}, _redirectDepth = 0) {
|
|
2625
|
+
const MAX_REDIRECTS = 5;
|
|
2626
|
+
const REQUEST_TIMEOUT_MS = 10000;
|
|
2107
2627
|
return new Promise((resolve, reject) => {
|
|
2108
2628
|
const request = https_1.default.get(url, { headers }, (response) => {
|
|
2109
2629
|
const statusCode = response.statusCode ?? 0;
|
|
2110
2630
|
if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
|
|
2111
2631
|
response.resume();
|
|
2632
|
+
if (_redirectDepth >= MAX_REDIRECTS) {
|
|
2633
|
+
reject(new Error(`Too many redirects fetching ${url}`));
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2112
2636
|
const redirect = new URL(response.headers.location, url).toString();
|
|
2113
|
-
httpsJson(redirect, headers).then(resolve).catch(reject);
|
|
2637
|
+
httpsJson(redirect, headers, _redirectDepth + 1).then(resolve).catch(reject);
|
|
2114
2638
|
return;
|
|
2115
2639
|
}
|
|
2116
2640
|
if (statusCode < 200 || statusCode >= 300) {
|
|
@@ -2132,7 +2656,7 @@ function httpsJson(url, headers = {}) {
|
|
|
2132
2656
|
}
|
|
2133
2657
|
});
|
|
2134
2658
|
});
|
|
2135
|
-
request.setTimeout(
|
|
2659
|
+
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
2136
2660
|
request.destroy(new Error(`Timed out fetching ${url}`));
|
|
2137
2661
|
});
|
|
2138
2662
|
request.on('error', reject);
|
|
@@ -2371,6 +2895,10 @@ function escapeForSingleQuotedPowerShell(value) {
|
|
|
2371
2895
|
return value.replace(/'/g, "''");
|
|
2372
2896
|
}
|
|
2373
2897
|
function resolveWindowsDetachedExecutable(executable) {
|
|
2898
|
+
// H-5: Validate executable name — reject empty strings or strings with shell metacharacters
|
|
2899
|
+
if (!executable || /[;&|<>\n\r`$(){}[\]\\/"']/.test(executable)) {
|
|
2900
|
+
throw new Error(`Invalid executable name: "${executable}"`);
|
|
2901
|
+
}
|
|
2374
2902
|
if (path_1.default.isAbsolute(executable)) {
|
|
2375
2903
|
return executable;
|
|
2376
2904
|
}
|
|
@@ -2427,7 +2955,19 @@ function launchDetachedWindowsPowerShellFile(scriptPath, cwd) {
|
|
|
2427
2955
|
throw new Error(`Failed to schedule detached PowerShell handoff. ${(proc.stderr || proc.stdout).trim() || 'powershell returned a non-zero exit code.'}`);
|
|
2428
2956
|
}
|
|
2429
2957
|
}
|
|
2958
|
+
// C-5: postCommand must be a pre-escaped PowerShell snippet produced internally (never from raw user input).
|
|
2959
|
+
// Validate it against a strict allowlist pattern to prevent future injection if the call site changes.
|
|
2960
|
+
function validateDetachedPostCommand(postCommand) {
|
|
2961
|
+
// Allow only: alphanumeric, spaces, single-quotes, hyphens, underscores, dots, slashes,
|
|
2962
|
+
// backslashes, colons, and & for PS call operator.
|
|
2963
|
+
if (!/^[a-zA-Z0-9 '&_\-./:\\]+$/.test(postCommand)) {
|
|
2964
|
+
throw new Error(`Unsafe characters in detached post-command. Only pre-escaped PowerShell call expressions are permitted.`);
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2430
2967
|
function scheduleDetachedWindowsGlobalNpmUpgrade(command, postCommand) {
|
|
2968
|
+
if (postCommand !== undefined) {
|
|
2969
|
+
validateDetachedPostCommand(postCommand);
|
|
2970
|
+
}
|
|
2431
2971
|
const neutralCwd = resolveDetachedUpgradeCwd(command);
|
|
2432
2972
|
const parentPid = process.pid;
|
|
2433
2973
|
const escapedCwd = escapeForSingleQuotedPowerShell(neutralCwd);
|
|
@@ -3322,7 +3862,11 @@ async function setupCommand(args) {
|
|
|
3322
3862
|
console.log(`${infoLabel()} Creating new instance '${instanceName}'.`);
|
|
3323
3863
|
}
|
|
3324
3864
|
const existingPort = Number.parseInt(existingInstance?.env.IRANTI_PORT ?? '3001', 10);
|
|
3325
|
-
const
|
|
3865
|
+
const existingInstancePorts = await readAllInstancePorts(finalRoot);
|
|
3866
|
+
// Exclude the current instance's own port from the reserved set when updating
|
|
3867
|
+
if (existingInstance)
|
|
3868
|
+
existingInstancePorts.delete(existingPort);
|
|
3869
|
+
const port = await chooseAvailablePort(prompt, 'API port', existingPort, Boolean(existingInstance), existingInstancePorts);
|
|
3326
3870
|
const dockerStatus = inspectDockerAvailability();
|
|
3327
3871
|
const dockerAvailable = dockerStatus.daemonReachable;
|
|
3328
3872
|
const psqlAvailable = hasCommandInstalled('psql');
|
|
@@ -3364,7 +3908,7 @@ async function setupCommand(args) {
|
|
|
3364
3908
|
}
|
|
3365
3909
|
const dbHostPort = await chooseAvailablePort(prompt, 'Docker PostgreSQL host port', 5432, false);
|
|
3366
3910
|
const dbName = sanitizeIdentifier(await promptNonEmpty(prompt, 'Docker PostgreSQL database name', `iranti_${instanceName}`), `iranti_${instanceName}`);
|
|
3367
|
-
const dbPassword = await
|
|
3911
|
+
const dbPassword = await promptSecretWithDefault(prompt, 'Docker PostgreSQL password', 'postgres');
|
|
3368
3912
|
const containerName = sanitizeIdentifier(await promptNonEmpty(prompt, 'Docker container name', `iranti_${instanceName}_db`), `iranti_${instanceName}_db`);
|
|
3369
3913
|
dockerContainerName = containerName;
|
|
3370
3914
|
dbUrl = `postgresql://postgres:${dbPassword}@localhost:${dbHostPort}/${dbName}`;
|
|
@@ -3742,51 +4286,67 @@ async function doctorCommand(args) {
|
|
|
3742
4286
|
}
|
|
3743
4287
|
async function statusCommand(args) {
|
|
3744
4288
|
const scope = normalizeScope(getFlag(args, 'scope'));
|
|
3745
|
-
const
|
|
4289
|
+
const resolution = resolveInstallRootDetails(args, scope);
|
|
4290
|
+
const root = resolution.root;
|
|
3746
4291
|
const json = hasFlag(args, 'json');
|
|
3747
4292
|
const cwd = process.cwd();
|
|
3748
|
-
const repoEnv =
|
|
3749
|
-
const projectEnv =
|
|
3750
|
-
const
|
|
3751
|
-
const
|
|
4293
|
+
const repoEnv = findClosestAncestorFile(cwd, '.env');
|
|
4294
|
+
const projectEnv = findClosestAncestorFile(cwd, '.env.iranti');
|
|
4295
|
+
const localRuntimeRoot = findClosestAncestorRuntimeRoot(cwd);
|
|
4296
|
+
const installMetaPath = resolution.installMetaPath;
|
|
4297
|
+
const binding = projectEnv && fs_1.default.existsSync(projectEnv) ? await inspectProjectBinding(projectEnv) : null;
|
|
4298
|
+
const boundRuntimeRoot = binding?.runtimeRoot ?? null;
|
|
4299
|
+
const boundInstanceEnv = binding?.instanceEnvFile ?? null;
|
|
4300
|
+
const rootMismatch = Boolean(boundRuntimeRoot && path_1.default.resolve(boundRuntimeRoot) !== path_1.default.resolve(root));
|
|
4301
|
+
const userInstallRuntimeRoot = fs_1.default.existsSync(path_1.default.join(resolution.userRoot, 'install.json')) ? resolution.userRoot : null;
|
|
4302
|
+
const systemInstallRuntimeRoot = fs_1.default.existsSync(path_1.default.join(resolution.systemRoot, 'install.json')) ? resolution.systemRoot : null;
|
|
4303
|
+
const otherRuntimeRoots = Array.from(new Set([boundRuntimeRoot, localRuntimeRoot]
|
|
4304
|
+
.filter((candidate) => Boolean(candidate))
|
|
4305
|
+
.map((candidate) => path_1.default.resolve(candidate))
|
|
4306
|
+
.filter((candidate) => candidate !== path_1.default.resolve(root) && fs_1.default.existsSync(candidate))));
|
|
3752
4307
|
const rows = [];
|
|
3753
4308
|
rows.push({ label: 'version', value: getPackageVersion() });
|
|
3754
4309
|
rows.push({ label: 'scope', value: scope });
|
|
3755
4310
|
rows.push({ label: 'runtime_root', value: root });
|
|
3756
|
-
rows.push({ label: '
|
|
3757
|
-
|
|
4311
|
+
rows.push({ label: 'root_source', value: describeRuntimeRootSource(resolution.source) });
|
|
4312
|
+
if (boundRuntimeRoot)
|
|
4313
|
+
rows.push({ label: 'bound_root', value: boundRuntimeRoot });
|
|
4314
|
+
rows.push({ label: 'repo_env', value: repoEnv && fs_1.default.existsSync(repoEnv) ? repoEnv : '(missing)' });
|
|
4315
|
+
rows.push({ label: 'project_binding', value: projectEnv && fs_1.default.existsSync(projectEnv) ? projectEnv : '(missing)' });
|
|
3758
4316
|
rows.push({ label: 'install_meta', value: fs_1.default.existsSync(installMetaPath) ? installMetaPath : '(not initialized)' });
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
const envFile = path_1.default.join(instancesDir, entry.name, '.env');
|
|
3764
|
-
let port = '(unknown)';
|
|
3765
|
-
if (fs_1.default.existsSync(envFile)) {
|
|
3766
|
-
try {
|
|
3767
|
-
const env = await readEnvFile(envFile);
|
|
3768
|
-
port = env.IRANTI_PORT ?? '(unknown)';
|
|
3769
|
-
}
|
|
3770
|
-
catch {
|
|
3771
|
-
port = '(unreadable)';
|
|
3772
|
-
}
|
|
3773
|
-
}
|
|
3774
|
-
instances.push({
|
|
3775
|
-
name: entry.name,
|
|
3776
|
-
port,
|
|
3777
|
-
envFile: fs_1.default.existsSync(envFile) ? envFile : '(missing)',
|
|
3778
|
-
runtime: await readInstanceRuntimeSummary(root, entry.name),
|
|
3779
|
-
});
|
|
3780
|
-
}
|
|
3781
|
-
}
|
|
4317
|
+
if (rootMismatch)
|
|
4318
|
+
rows.push({ label: 'root_mismatch', value: 'project binding points at a different runtime root' });
|
|
4319
|
+
const instances = await collectRuntimeInstanceSummaries(root);
|
|
4320
|
+
const recommendedActions = Array.from(new Set(instances.flatMap((instance) => instance.repairHints)));
|
|
3782
4321
|
if (json) {
|
|
3783
4322
|
console.log(JSON.stringify({
|
|
3784
4323
|
version: getPackageVersion(),
|
|
3785
4324
|
scope,
|
|
3786
4325
|
runtimeRoot: root,
|
|
3787
|
-
|
|
3788
|
-
|
|
4326
|
+
runtimeRootSource: resolution.source,
|
|
4327
|
+
discovery: {
|
|
4328
|
+
selectedRuntimeRoot: root,
|
|
4329
|
+
selectionSource: resolution.source,
|
|
4330
|
+
selectionReason: describeRuntimeRootSource(resolution.source),
|
|
4331
|
+
boundRuntimeRoot,
|
|
4332
|
+
boundInstanceEnv,
|
|
4333
|
+
ancestorRuntimeRoot: localRuntimeRoot,
|
|
4334
|
+
userInstallRuntimeRoot,
|
|
4335
|
+
systemInstallRuntimeRoot,
|
|
4336
|
+
projectBindingFile: projectEnv && fs_1.default.existsSync(projectEnv) ? projectEnv : null,
|
|
4337
|
+
projectBindingSource: projectEnv && fs_1.default.existsSync(projectEnv) ? 'ancestor-project-binding' : null,
|
|
4338
|
+
repoEnvFile: repoEnv && fs_1.default.existsSync(repoEnv) ? repoEnv : null,
|
|
4339
|
+
rootMismatch,
|
|
4340
|
+
otherRuntimeRoots,
|
|
4341
|
+
},
|
|
4342
|
+
boundRuntimeRoot,
|
|
4343
|
+
boundInstanceEnv,
|
|
4344
|
+
rootMismatch,
|
|
4345
|
+
otherRuntimeRoots,
|
|
4346
|
+
repoEnv: repoEnv && fs_1.default.existsSync(repoEnv) ? repoEnv : null,
|
|
4347
|
+
projectBinding: projectEnv && fs_1.default.existsSync(projectEnv) ? projectEnv : null,
|
|
3789
4348
|
installMeta: fs_1.default.existsSync(installMetaPath) ? installMetaPath : null,
|
|
4349
|
+
recommendedActions,
|
|
3790
4350
|
instances,
|
|
3791
4351
|
}, null, 2));
|
|
3792
4352
|
return;
|
|
@@ -3795,6 +4355,12 @@ async function statusCommand(args) {
|
|
|
3795
4355
|
for (const row of rows) {
|
|
3796
4356
|
console.log(` ${row.label.padEnd(15)} ${row.value}`);
|
|
3797
4357
|
}
|
|
4358
|
+
if (otherRuntimeRoots.length > 0) {
|
|
4359
|
+
console.log(' other_roots');
|
|
4360
|
+
for (const runtimeRoot of otherRuntimeRoots) {
|
|
4361
|
+
console.log(` - ${runtimeRoot}`);
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
3798
4364
|
console.log('');
|
|
3799
4365
|
if (instances.length === 0) {
|
|
3800
4366
|
console.log('Instances: none');
|
|
@@ -3804,17 +4370,24 @@ async function statusCommand(args) {
|
|
|
3804
4370
|
for (const instance of instances) {
|
|
3805
4371
|
console.log(` - ${instance.name} (port ${instance.port})`);
|
|
3806
4372
|
console.log(` env: ${instance.envFile}`);
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
4373
|
+
console.log(` meta: ${instance.metaFile}`);
|
|
4374
|
+
console.log(` config: ${describeInstanceConfig(instance.config)}`);
|
|
4375
|
+
console.log(` runtime: ${describeInstanceRuntime(instance.runtime)}`);
|
|
4376
|
+
if (instance.repairHints.length > 0) {
|
|
4377
|
+
console.log(' hints:');
|
|
4378
|
+
for (const hint of instance.repairHints) {
|
|
4379
|
+
console.log(` - ${hint}`);
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
if (instance.runtime.state?.healthUrl) {
|
|
3814
4383
|
console.log(` health: ${instance.runtime.state.healthUrl}`);
|
|
3815
4384
|
}
|
|
3816
|
-
|
|
3817
|
-
|
|
4385
|
+
}
|
|
4386
|
+
if (recommendedActions.length > 0) {
|
|
4387
|
+
console.log('');
|
|
4388
|
+
console.log('Suggested fixes:');
|
|
4389
|
+
for (const action of recommendedActions) {
|
|
4390
|
+
console.log(` - ${action}`);
|
|
3818
4391
|
}
|
|
3819
4392
|
}
|
|
3820
4393
|
}
|
|
@@ -3827,9 +4400,10 @@ async function collectRuntimeInstanceSummaries(root) {
|
|
|
3827
4400
|
}
|
|
3828
4401
|
const entries = await promises_1.default.readdir(instancesDir, { withFileTypes: true });
|
|
3829
4402
|
for (const entry of entries.filter((value) => value.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3830
|
-
const envFile =
|
|
4403
|
+
const { envFile, metaFile } = instancePaths(root, entry.name);
|
|
4404
|
+
const config = await inspectInstanceConfig(root, entry.name);
|
|
3831
4405
|
let port = '(unknown)';
|
|
3832
|
-
if (
|
|
4406
|
+
if (config.state.envPresent && config.state.envReadable) {
|
|
3833
4407
|
try {
|
|
3834
4408
|
const env = await readEnvFile(envFile);
|
|
3835
4409
|
port = env.IRANTI_PORT ?? '(unknown)';
|
|
@@ -3838,11 +4412,15 @@ async function collectRuntimeInstanceSummaries(root) {
|
|
|
3838
4412
|
port = '(unreadable)';
|
|
3839
4413
|
}
|
|
3840
4414
|
}
|
|
4415
|
+
const runtime = await readInstanceRuntimeSummary(root, entry.name);
|
|
3841
4416
|
instances.push({
|
|
3842
4417
|
name: entry.name,
|
|
3843
4418
|
port,
|
|
3844
|
-
envFile:
|
|
3845
|
-
|
|
4419
|
+
envFile: config.state.envPresent ? envFile : '(missing)',
|
|
4420
|
+
metaFile: config.state.metaPresent ? metaFile : '(missing)',
|
|
4421
|
+
config,
|
|
4422
|
+
runtime,
|
|
4423
|
+
repairHints: buildInstanceRepairHints(entry.name, config, runtime),
|
|
3846
4424
|
});
|
|
3847
4425
|
}
|
|
3848
4426
|
return instances;
|
|
@@ -4108,9 +4686,20 @@ async function createInstanceCommand(args) {
|
|
|
4108
4686
|
throw new Error(`Provider '${provider}' does not use a remote API key.`);
|
|
4109
4687
|
}
|
|
4110
4688
|
const { instanceDir, envFile, metaFile } = instancePaths(root, name);
|
|
4111
|
-
|
|
4689
|
+
const instanceAlreadyExisted = fs_1.default.existsSync(instanceDir);
|
|
4690
|
+
if (instanceAlreadyExisted && !hasFlag(args, 'force')) {
|
|
4112
4691
|
throw new Error(`Instance '${name}' already exists at ${instanceDir}. Use --force to overwrite.`);
|
|
4113
4692
|
}
|
|
4693
|
+
await assertPortAssignable(root, port, instanceAlreadyExisted ? name : undefined);
|
|
4694
|
+
// H-7: Register rollback if the instance dir is new (so SIGINT cleans up partial state)
|
|
4695
|
+
if (!instanceAlreadyExisted) {
|
|
4696
|
+
pushCleanup(async () => {
|
|
4697
|
+
try {
|
|
4698
|
+
await promises_1.default.rm(instanceDir, { recursive: true, force: true });
|
|
4699
|
+
}
|
|
4700
|
+
catch { }
|
|
4701
|
+
});
|
|
4702
|
+
}
|
|
4114
4703
|
await ensureDir(instanceDir);
|
|
4115
4704
|
await ensureDir(path_1.default.join(instanceDir, 'logs'));
|
|
4116
4705
|
await ensureDir(path_1.default.join(instanceDir, 'escalation', 'active'));
|
|
@@ -4129,6 +4718,9 @@ async function createInstanceCommand(args) {
|
|
|
4129
4718
|
instanceDir,
|
|
4130
4719
|
};
|
|
4131
4720
|
await writeJson(metaFile, meta);
|
|
4721
|
+
// Instance fully created — pop the rollback so it doesn't run on normal exit
|
|
4722
|
+
if (!instanceAlreadyExisted)
|
|
4723
|
+
popCleanup();
|
|
4132
4724
|
console.log(sectionTitle('Instance Created'));
|
|
4133
4725
|
console.log(` status ${okLabel()}`);
|
|
4134
4726
|
console.log(` dir : ${instanceDir}`);
|
|
@@ -4183,15 +4775,18 @@ async function showInstanceCommand(args) {
|
|
|
4183
4775
|
throw new Error('Missing instance name. Usage: iranti instance show <name>');
|
|
4184
4776
|
const scope = normalizeScope(getFlag(args, 'scope'));
|
|
4185
4777
|
const root = resolveInstallRoot(args, scope);
|
|
4186
|
-
const instanceDir =
|
|
4187
|
-
const
|
|
4188
|
-
if (!
|
|
4778
|
+
const { instanceDir, envFile } = instancePaths(root, name);
|
|
4779
|
+
const config = await inspectInstanceConfig(root, name);
|
|
4780
|
+
if (!config.state.metaPresent && !config.state.envPresent)
|
|
4189
4781
|
throw new Error(`Instance '${name}' not found at ${instanceDir}`);
|
|
4190
|
-
const env =
|
|
4782
|
+
const env = config.state.envPresent && config.state.envReadable
|
|
4783
|
+
? await readEnvFile(envFile)
|
|
4784
|
+
: {};
|
|
4191
4785
|
const runtime = await readInstanceRuntimeSummary(root, name);
|
|
4192
4786
|
console.log(bold(`Instance: ${name}`));
|
|
4193
4787
|
console.log(` dir : ${instanceDir}`);
|
|
4194
4788
|
console.log(` env : ${envFile}`);
|
|
4789
|
+
console.log(` config: ${describeInstanceConfig(config)}`);
|
|
4195
4790
|
console.log(` port: ${env.IRANTI_PORT ?? '3001'}`);
|
|
4196
4791
|
console.log(` db : ${env.DATABASE_URL ?? '(missing)'}`);
|
|
4197
4792
|
console.log(` esc : ${env.IRANTI_ESCALATION_DIR ?? '(missing)'}`);
|
|
@@ -4208,11 +4803,7 @@ async function runInstanceCommand(args) {
|
|
|
4208
4803
|
}
|
|
4209
4804
|
const scope = normalizeScope(getFlag(args, 'scope'));
|
|
4210
4805
|
const root = resolveInstallRoot(args, scope);
|
|
4211
|
-
const { instanceDir, envFile, runtimeFile } =
|
|
4212
|
-
if (!fs_1.default.existsSync(envFile)) {
|
|
4213
|
-
throw cliError('IRANTI_INSTANCE_NOT_FOUND', `Instance '${name}' not found. Create it first.`, [`Run \`iranti setup\` or \`iranti instance create ${name}\` first.`], { instance: name, envFile });
|
|
4214
|
-
}
|
|
4215
|
-
const env = await readEnvFile(envFile);
|
|
4806
|
+
const { instanceDir, envFile, runtimeFile, env } = await loadInstanceEnv(root, name);
|
|
4216
4807
|
const runtime = await readInstanceRuntimeSummary(root, name);
|
|
4217
4808
|
if (runtime.running) {
|
|
4218
4809
|
throw cliError('IRANTI_INSTANCE_ALREADY_RUNNING', `Instance '${name}' is already running on pid ${runtime.state?.pid ?? '(unknown)'}.`, [`Run \`iranti instance restart ${name}\` to restart the live process, or stop the existing process first.`], { instance: name, pid: runtime.state?.pid ?? null, runtimeFile });
|
|
@@ -4226,6 +4817,13 @@ async function runInstanceCommand(args) {
|
|
|
4226
4817
|
if (!process.env.DATABASE_URL || process.env.DATABASE_URL.includes('yourpassword')) {
|
|
4227
4818
|
throw cliError('IRANTI_INSTANCE_DATABASE_PLACEHOLDER', `Instance '${name}' has placeholder DATABASE_URL. Edit ${envFile} first.`, ['Run `iranti configure instance <name> --interactive` or rerun `iranti setup`.'], { instance: name, envFile });
|
|
4228
4819
|
}
|
|
4820
|
+
const port = Number.parseInt(env.IRANTI_PORT ?? '3001', 10);
|
|
4821
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
4822
|
+
throw cliError('IRANTI_INSTANCE_PORT_INVALID', `Instance '${name}' has invalid IRANTI_PORT in ${envFile}.`, ['Run `iranti configure instance <name> --port <n>` to repair it.'], { instance: name, envFile, port: env.IRANTI_PORT ?? null });
|
|
4823
|
+
}
|
|
4824
|
+
if (!(await isPortUsable(port, '0.0.0.0', listPublishedDockerHostPorts()))) {
|
|
4825
|
+
throw cliError('IRANTI_INSTANCE_PORT_IN_USE', `Cannot start instance '${name}' because port ${port} is already in use.`, ['Run `iranti configure instance <name> --port <n>` or free the port before retrying.'], { instance: name, envFile, port });
|
|
4826
|
+
}
|
|
4229
4827
|
await startInstanceRuntime(name, instanceDir, envFile, runtimeFile);
|
|
4230
4828
|
}
|
|
4231
4829
|
async function restartInstanceCommand(args) {
|
|
@@ -4293,7 +4891,7 @@ async function configureInstanceCommand(args) {
|
|
|
4293
4891
|
}
|
|
4294
4892
|
const scope = normalizeScope(getFlag(args, 'scope'));
|
|
4295
4893
|
const root = resolveInstallRoot(args, scope);
|
|
4296
|
-
const { envFile, env } = await loadInstanceEnv(root, name);
|
|
4894
|
+
const { instanceDir, envFile, env, config } = await loadInstanceEnv(root, name, { allowRepair: true });
|
|
4297
4895
|
const updates = {};
|
|
4298
4896
|
let portRaw = getFlag(args, 'port');
|
|
4299
4897
|
let dbUrl = getFlag(args, 'db-url');
|
|
@@ -4319,6 +4917,7 @@ async function configureInstanceCommand(args) {
|
|
|
4319
4917
|
const port = Number.parseInt(portRaw, 10);
|
|
4320
4918
|
if (!Number.isFinite(port) || port <= 0)
|
|
4321
4919
|
throw new Error(`Invalid --port '${portRaw}'.`);
|
|
4920
|
+
await assertPortAssignable(root, port, name);
|
|
4322
4921
|
updates.IRANTI_PORT = String(port);
|
|
4323
4922
|
}
|
|
4324
4923
|
if (dbUrl)
|
|
@@ -4345,7 +4944,26 @@ async function configureInstanceCommand(args) {
|
|
|
4345
4944
|
if (Object.keys(updates).length === 0) {
|
|
4346
4945
|
throw new Error('No changes provided. Use flags like --provider, --provider-key, --api-key, --db-url, or --port.');
|
|
4347
4946
|
}
|
|
4947
|
+
const nextEnv = { ...env };
|
|
4948
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
4949
|
+
if (value === undefined) {
|
|
4950
|
+
delete nextEnv[key];
|
|
4951
|
+
}
|
|
4952
|
+
else {
|
|
4953
|
+
nextEnv[key] = value;
|
|
4954
|
+
}
|
|
4955
|
+
}
|
|
4956
|
+
const nextPortRaw = nextEnv.IRANTI_PORT?.trim();
|
|
4957
|
+
const nextPort = Number.parseInt(nextPortRaw ?? '', 10);
|
|
4958
|
+
if (!Number.isFinite(nextPort) || nextPort <= 0) {
|
|
4959
|
+
throw cliError('IRANTI_INSTANCE_PORT_REQUIRED', `Instance '${name}' still needs a valid IRANTI_PORT before it can be considered repaired.`, ['Pass `--port <n>` or rerun `iranti configure instance <name> --interactive`.'], { instance: name, envFile, port: nextPortRaw ?? null, config: config.classification });
|
|
4960
|
+
}
|
|
4961
|
+
if (!nextEnv.DATABASE_URL?.trim()) {
|
|
4962
|
+
throw cliError('IRANTI_INSTANCE_DATABASE_REQUIRED', `Instance '${name}' still needs DATABASE_URL before it can be considered repaired.`, ['Pass `--db-url <postgresql://...>` or rerun `iranti configure instance <name> --interactive`.'], { instance: name, envFile, config: config.classification });
|
|
4963
|
+
}
|
|
4964
|
+
await ensureDir(instanceDir);
|
|
4348
4965
|
await upsertEnvFile(envFile, updates);
|
|
4966
|
+
await syncInstanceMeta(root, name, nextPort);
|
|
4349
4967
|
const json = hasFlag(args, 'json');
|
|
4350
4968
|
const result = {
|
|
4351
4969
|
instance: name,
|
|
@@ -4638,6 +5256,113 @@ async function attendCommand(args) {
|
|
|
4638
5256
|
console.log('');
|
|
4639
5257
|
console.log(`${infoLabel()} This is a manual Attendant inspection tool. Claude Code should still use hooks + MCP in normal operation.`);
|
|
4640
5258
|
}
|
|
5259
|
+
async function handoffCommand(args) {
|
|
5260
|
+
const json = hasFlag(args, 'json');
|
|
5261
|
+
const target = await resolveAttendantCliTarget(args);
|
|
5262
|
+
const taskEntity = resolveTaskEntity(args);
|
|
5263
|
+
const projectEntity = getFlag(args, 'project-entity')?.trim();
|
|
5264
|
+
if (projectEntity && !projectEntity.includes('/')) {
|
|
5265
|
+
throw new Error('project-entity must use entityType/entityId format.');
|
|
5266
|
+
}
|
|
5267
|
+
const nextStep = getFlag(args, 'next-step')?.trim();
|
|
5268
|
+
if (!nextStep) {
|
|
5269
|
+
throw new Error('Missing --next-step. A standardized handoff must record the receiver action.');
|
|
5270
|
+
}
|
|
5271
|
+
const status = getFlag(args, 'status')?.trim() || 'ready_for_handoff';
|
|
5272
|
+
const owner = getFlag(args, 'owner')?.trim();
|
|
5273
|
+
const blockers = parseDelimitedList(getFlag(args, 'blockers'));
|
|
5274
|
+
const artifacts = parseDelimitedList(getFlag(args, 'artifacts'));
|
|
5275
|
+
const notes = getFlag(args, 'notes')?.trim();
|
|
5276
|
+
const source = getFlag(args, 'source')?.trim() || 'CLIHandoff';
|
|
5277
|
+
const confidence = parsePositiveInteger(getFlag(args, 'confidence'), 'confidence') ?? 95;
|
|
5278
|
+
if (confidence > 100) {
|
|
5279
|
+
throw new Error('confidence must be <= 100.');
|
|
5280
|
+
}
|
|
5281
|
+
const writes = [];
|
|
5282
|
+
writes.push({
|
|
5283
|
+
entity: taskEntity,
|
|
5284
|
+
key: 'status',
|
|
5285
|
+
value: { state: status },
|
|
5286
|
+
summary: buildHandoffSummary('status', { state: status }),
|
|
5287
|
+
});
|
|
5288
|
+
writes.push({
|
|
5289
|
+
entity: taskEntity,
|
|
5290
|
+
key: 'next_step',
|
|
5291
|
+
value: { instruction: nextStep },
|
|
5292
|
+
summary: buildHandoffSummary('next_step', { instruction: nextStep }),
|
|
5293
|
+
});
|
|
5294
|
+
if (owner) {
|
|
5295
|
+
writes.push({
|
|
5296
|
+
entity: taskEntity,
|
|
5297
|
+
key: 'current_owner',
|
|
5298
|
+
value: { agentId: owner },
|
|
5299
|
+
summary: buildHandoffSummary('current_owner', { agentId: owner }),
|
|
5300
|
+
});
|
|
5301
|
+
}
|
|
5302
|
+
if (blockers.length > 0) {
|
|
5303
|
+
writes.push({
|
|
5304
|
+
entity: taskEntity,
|
|
5305
|
+
key: 'blockers',
|
|
5306
|
+
value: { items: blockers },
|
|
5307
|
+
summary: buildHandoffSummary('blockers', { items: blockers }),
|
|
5308
|
+
});
|
|
5309
|
+
}
|
|
5310
|
+
if (artifacts.length > 0) {
|
|
5311
|
+
writes.push({
|
|
5312
|
+
entity: taskEntity,
|
|
5313
|
+
key: 'artifacts',
|
|
5314
|
+
value: { files: artifacts },
|
|
5315
|
+
summary: buildHandoffSummary('artifacts', { files: artifacts }),
|
|
5316
|
+
});
|
|
5317
|
+
}
|
|
5318
|
+
if (notes) {
|
|
5319
|
+
writes.push({
|
|
5320
|
+
entity: taskEntity,
|
|
5321
|
+
key: 'notes',
|
|
5322
|
+
value: { text: notes },
|
|
5323
|
+
summary: buildHandoffSummary('notes', { text: notes }),
|
|
5324
|
+
});
|
|
5325
|
+
}
|
|
5326
|
+
if (projectEntity) {
|
|
5327
|
+
writes.push({
|
|
5328
|
+
entity: projectEntity,
|
|
5329
|
+
key: 'active_handoff_task',
|
|
5330
|
+
value: {
|
|
5331
|
+
taskEntity,
|
|
5332
|
+
owner: owner ?? null,
|
|
5333
|
+
status,
|
|
5334
|
+
updatedBy: target.agentId,
|
|
5335
|
+
},
|
|
5336
|
+
summary: buildHandoffSummary('active_handoff_task', { taskEntity }),
|
|
5337
|
+
});
|
|
5338
|
+
}
|
|
5339
|
+
for (const write of writes) {
|
|
5340
|
+
await target.iranti.write({
|
|
5341
|
+
entity: write.entity,
|
|
5342
|
+
key: write.key,
|
|
5343
|
+
value: write.value,
|
|
5344
|
+
summary: write.summary,
|
|
5345
|
+
confidence,
|
|
5346
|
+
source,
|
|
5347
|
+
agent: target.agentId,
|
|
5348
|
+
});
|
|
5349
|
+
}
|
|
5350
|
+
if (json) {
|
|
5351
|
+
console.log(JSON.stringify({
|
|
5352
|
+
agent: target.agentId,
|
|
5353
|
+
envSource: target.envSource,
|
|
5354
|
+
envFile: target.envFile,
|
|
5355
|
+
source,
|
|
5356
|
+
confidence,
|
|
5357
|
+
writes,
|
|
5358
|
+
}, null, 2));
|
|
5359
|
+
process.exit(0);
|
|
5360
|
+
}
|
|
5361
|
+
printHandoffResult(target, taskEntity, writes);
|
|
5362
|
+
console.log('');
|
|
5363
|
+
console.log(`${infoLabel()} Handoffs are shared-memory facts. Pair this with checkpoint() if the sender also needs agent-local recovery.`);
|
|
5364
|
+
process.exit(0);
|
|
5365
|
+
}
|
|
4641
5366
|
function printClaudeSetupHelp() {
|
|
4642
5367
|
console.log([
|
|
4643
5368
|
'Scaffold Claude Code MCP and hook files for the current project.',
|
|
@@ -4930,12 +5655,14 @@ function printHelp() {
|
|
|
4930
5655
|
['iranti remove api-key [provider] [--instance <name>] [--project <path>] [--json]', 'Remove a stored provider key.'],
|
|
4931
5656
|
]);
|
|
4932
5657
|
printRows('Diagnostics And Operator Tools', [
|
|
5658
|
+
['iranti version', 'Print the installed CLI version and exit.'],
|
|
4933
5659
|
['iranti doctor [--instance <name>] [--scope user|system] [--env <file>] [--json] [--debug]', 'Run environment and runtime diagnostics.'],
|
|
4934
5660
|
['iranti status [--scope user|system] [--json]', 'Show runtime roots, bindings, and known instances.'],
|
|
4935
5661
|
['iranti upgrade [--check] [--dry-run] [--yes] [--all] [--target auto|npm-global|npm-repo|python[,python]] [--json]', 'Check or run CLI/runtime/package upgrades.'],
|
|
4936
5662
|
['iranti uninstall [--dry-run] [--yes] [--all] [--keep-data] [--keep-project-bindings] [--scan-root <dir[,dir2]>] [--json]', 'Remove Iranti packages and, with --all, runtime data and project integrations.'],
|
|
4937
5663
|
['iranti handshake [--instance <name> | --project-env <file>] [--agent <id>] [--task <text>] [--recent <msg1||msg2>] [--recent-file <path>] [--json]', 'Manually inspect Attendant handshake output.'],
|
|
4938
5664
|
['iranti attend [message] [--instance <name> | --project-env <file>] [--agent <id>] [--context <text> | --context-file <path>] [--entity-hint <entity>] [--force] [--max-facts <n>] [--json]', 'Manually inspect turn-level memory injection decisions.'],
|
|
5665
|
+
['iranti handoff task/<task_id> [--instance <name> | --project-env <file>] [--agent <id>] --next-step <text> [--status <state>] [--owner <agent-id>] [--blockers <a||b>] [--artifacts <path1||path2>] [--project-entity <entity>] [--notes <text>] [--source <label>] [--confidence <n>] [--json]', 'Write a standardized shared-memory handoff for Claude/Codex collaboration.'],
|
|
4939
5666
|
['iranti chat [--agent <agent-id>] [--provider <provider>] [--model <model>]', 'Open the local interactive chat shell.'],
|
|
4940
5667
|
['iranti resolve [--dir <escalation-dir>]', 'Walk through pending escalation files.'],
|
|
4941
5668
|
]);
|
|
@@ -5021,6 +5748,10 @@ async function main() {
|
|
|
5021
5748
|
subcommand: args.subcommand,
|
|
5022
5749
|
cwd: process.cwd(),
|
|
5023
5750
|
});
|
|
5751
|
+
if (args.command === '--version' || args.command === 'version' || hasFlag(args, 'version')) {
|
|
5752
|
+
console.log(getPackageVersion());
|
|
5753
|
+
return;
|
|
5754
|
+
}
|
|
5024
5755
|
if (!args.command || args.command === 'help' || args.command === '--help') {
|
|
5025
5756
|
printHelp();
|
|
5026
5757
|
return;
|
|
@@ -5162,6 +5893,10 @@ async function main() {
|
|
|
5162
5893
|
await attendCommand(args);
|
|
5163
5894
|
return;
|
|
5164
5895
|
}
|
|
5896
|
+
if (args.command === 'handoff') {
|
|
5897
|
+
await handoffCommand(args);
|
|
5898
|
+
return;
|
|
5899
|
+
}
|
|
5165
5900
|
if (args.command === 'chat') {
|
|
5166
5901
|
await chatCommand(args);
|
|
5167
5902
|
return;
|