@bvdm/delano 0.2.3 → 0.2.4

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.
@@ -12,7 +12,16 @@ const { spawn, spawnSync } = require('node:child_process');
12
12
  const repoRoot = path.resolve(process.env.DELANO_VIEWER_ROOT || path.resolve(__dirname, '..', '..'));
13
13
  const projectRoot = path.join(repoRoot, '.project');
14
14
  const publicRoot = path.join(__dirname, 'public');
15
- const port = Number(process.env.DELANO_VIEWER_PORT || process.env.PORT || 3977);
15
+ const DEFAULT_PORT = 3977;
16
+ const MAX_PORT = 65535;
17
+ const MAX_PORT_ATTEMPTS = 100;
18
+ const startPort = normalizePort(process.env.DELANO_VIEWER_PORT || process.env.PORT, DEFAULT_PORT);
19
+
20
+ function normalizePort(value, fallback) {
21
+ const parsed = Number(value || fallback);
22
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_PORT) return fallback;
23
+ return parsed;
24
+ }
16
25
 
17
26
  function isInside(parent, child) {
18
27
  const rel = path.relative(parent, child);
@@ -332,6 +341,7 @@ function sendStatic(res, pathname) {
332
341
  const ext = path.extname(resolved).toLowerCase();
333
342
  const mimeMap = {
334
343
  '.js': 'text/javascript',
344
+ '.jsx': 'text/javascript',
335
345
  '.css': 'text/css',
336
346
  '.svg': 'image/svg+xml',
337
347
  '.png': 'image/png',
@@ -340,7 +350,7 @@ function sendStatic(res, pathname) {
340
350
  '.webp': 'image/webp',
341
351
  '.ico': 'image/x-icon',
342
352
  };
343
- const isText = ext === '.js' || ext === '.css' || ext === '.svg' || ext === '' || ext === '.html';
353
+ const isText = ext === '.js' || ext === '.jsx' || ext === '.css' || ext === '.svg' || ext === '' || ext === '.html';
344
354
  const type = mimeMap[ext] || 'text/html';
345
355
  const headers = isText ? { 'content-type': `${type}; charset=utf-8` } : { 'content-type': type };
346
356
  res.writeHead(200, headers);
@@ -384,6 +394,37 @@ const server = http.createServer((req, res) => {
384
394
  }
385
395
  });
386
396
 
387
- server.listen(port, '127.0.0.1', () => {
388
- console.log(`Delano read-only viewer: http://127.0.0.1:${port}`);
389
- });
397
+ function listenWithPortFallback(server, firstPort, host = '127.0.0.1') {
398
+ let port = firstPort;
399
+ let attempts = 0;
400
+
401
+ const listen = () => {
402
+ server.once('error', onError);
403
+ server.listen(port, host);
404
+ };
405
+
406
+ const onError = (error) => {
407
+ if (error.code === 'EADDRINUSE' && port < MAX_PORT && attempts < MAX_PORT_ATTEMPTS) {
408
+ attempts += 1;
409
+ port += 1;
410
+ listen();
411
+ return;
412
+ }
413
+
414
+ console.error(`Failed to start Delano viewer on ${host}:${port}: ${error.message}`);
415
+ process.exitCode = 1;
416
+ };
417
+
418
+ const onListening = () => {
419
+ server.removeListener('error', onError);
420
+ const address = server.address();
421
+ const actualPort = typeof address === 'object' && address ? address.port : port;
422
+ const skipped = actualPort !== firstPort ? ` (${firstPort} was unavailable)` : '';
423
+ console.log(`Delano read-only viewer: http://${host}:${actualPort}${skipped}`);
424
+ };
425
+
426
+ server.on('listening', onListening);
427
+ listen();
428
+ }
429
+
430
+ listenWithPortFallback(server, startPort);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bvdm/delano",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "CLI for the Delano delivery runtime.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,7 +30,7 @@ function getInstallHelp() {
30
30
  " -h, --help Show command help.",
31
31
  "",
32
32
  "Categories:",
33
- " agent-runtime, skills, viewer, project-context, project-templates,",
33
+ " agent-runtime, codex-hooks, skills, viewer, project-context, project-templates,",
34
34
  " project-registry, project-projects, handbook, legacy-installer",
35
35
  "",
36
36
  "Behavior:",
@@ -38,6 +38,7 @@ function getInstallHelp() {
38
38
  " - Aborts on conflicts by default.",
39
39
  " - Filters the plan before conflict detection when --only or --exclude is used.",
40
40
  " - Treats .project/context, .project/projects, and .project/registry as repo-owned state after install.",
41
+ " - Merges .codex/hooks.json when it exists; invalid or non-file hook configs are skipped without blocking install.",
41
42
  " - Only installs the approved base payload; top-level adapter entry docs remain opt-in and are not installed in v1.",
42
43
  "",
43
44
  "Examples:",
@@ -43,7 +43,8 @@ function getViewerHelp() {
43
43
  " -h, --help Show help",
44
44
  "",
45
45
  "Environment:",
46
- " DELANO_VIEWER_PORT or PORT overrides the default port 3977."
46
+ " DELANO_VIEWER_PORT or PORT sets the starting port, defaulting to 3977.",
47
+ " If that port is busy, the viewer starts on the next available port."
47
48
  ].join("\n");
48
49
  }
49
50
 
@@ -8,7 +8,7 @@ function createWrapperCommand(scriptName) {
8
8
  return runPmScript(scriptName, passthrough);
9
9
  },
10
10
  help() {
11
- return [
11
+ const lines = [
12
12
  "Usage:",
13
13
  ` delano ${scriptName} [-- <script-args>]`,
14
14
  "",
@@ -16,7 +16,18 @@ function createWrapperCommand(scriptName) {
16
16
  ` - Resolves the current Delano repository by searching upward for .project/ and .agents/scripts/pm/.`,
17
17
  ` - Runs .agents/scripts/pm/${scriptName}.sh through bash.`,
18
18
  " - Pass '--' to make argument passthrough explicit when needed."
19
- ].join("\n");
19
+ ];
20
+
21
+ if (scriptName === "status") {
22
+ lines.push(
23
+ "",
24
+ "Status examples:",
25
+ " delano status --open --brief",
26
+ " delano status -- --open --brief"
27
+ );
28
+ }
29
+
30
+ return lines.join("\n");
20
31
  }
21
32
  };
22
33
  }
package/src/cli/index.js CHANGED
@@ -116,6 +116,7 @@ function getGeneralHelp() {
116
116
  " npx -y @bvdm/delano@latest --yes",
117
117
  " delano viewer",
118
118
  " delano validate",
119
+ " delano status --open --brief",
119
120
  " delano next -- --all",
120
121
  "",
121
122
  "Shorthand:",
@@ -6,6 +6,7 @@ const {
6
6
  readFileSync,
7
7
  rmSync,
8
8
  statSync,
9
+ writeFileSync,
9
10
  } = require("node:fs");
10
11
  const path = require("node:path");
11
12
  const readline = require("node:readline");
@@ -14,8 +15,16 @@ const { stdin, stdout } = require("node:process");
14
15
  const { CliError } = require("./errors");
15
16
  const { getPackageRoot, getPathType } = require("./runtime");
16
17
 
18
+ const CODEX_HOOKS_TARGET = ".codex/hooks.json";
19
+ const CODEX_SESSION_STATUS_SCRIPT = ".agents/hooks/codex-session-status.js";
20
+
17
21
  const SUPPORTED_AGENTS = ["claude", "codex", "opencode", "pi"];
18
22
  const INSTALL_CATEGORIES = [
23
+ {
24
+ name: "codex-hooks",
25
+ description: ".codex hook configuration and SessionStart shim",
26
+ matches: (target) => target.startsWith(".codex/") || target === CODEX_SESSION_STATUS_SCRIPT
27
+ },
19
28
  {
20
29
  name: "agent-runtime",
21
30
  description: ".agents runtime except skills",
@@ -67,6 +76,8 @@ const INSTALL_CATEGORY_ALIASES = new Map([
67
76
  ["agent-skills", "skills"],
68
77
  ["agents", "agent-runtime"],
69
78
  ["runtime", "agent-runtime"],
79
+ ["codex", "codex-hooks"],
80
+ ["codex-config", "codex-hooks"],
70
81
  ["context", "project-context"],
71
82
  ["templates", "project-templates"],
72
83
  ["project-state", "project-projects"],
@@ -531,6 +542,9 @@ function collectConflicts(plan) {
531
542
 
532
543
  const exactType = getPathType(item.targetPath);
533
544
  if (exactType) {
545
+ if (isNonBlockingExistingTarget(item.relativePath)) {
546
+ continue;
547
+ }
534
548
  conflicts.push({
535
549
  relativePath: item.relativePath,
536
550
  conflictPath: item.relativePath,
@@ -566,6 +580,7 @@ function printPlanSummary(plan, options) {
566
580
  console.log(`Force: ${options.force ? "yes" : "no"}`);
567
581
  console.log("");
568
582
  console.log("Note: --agents is accepted now for forward compatibility, but v1 base install still excludes top-level adapter entry docs by default.");
583
+ console.log("Note: .codex/hooks.json is merged when it already exists, and Codex runs the hook only after hooks are enabled and trusted.");
569
584
  console.log("Note: .project/context, .project/projects, and .project/registry are repo-owned after install; use --no-project-state or --only for update-safe refreshes.");
570
585
  }
571
586
 
@@ -605,7 +620,20 @@ async function confirmInstall(plan, options) {
605
620
  }
606
621
 
607
622
  function applyInstallPlan(plan, options) {
623
+ let appliedCount = 0;
624
+ let skippedCount = 0;
625
+
608
626
  for (const item of plan.items) {
627
+ if (item.relativePath === CODEX_HOOKS_TARGET) {
628
+ const result = applyCodexHooksConfig(item);
629
+ if (result === "skipped") {
630
+ skippedCount += 1;
631
+ } else {
632
+ appliedCount += 1;
633
+ }
634
+ continue;
635
+ }
636
+
609
637
  const existingType = getPathType(item.targetPath);
610
638
  if (existingType) {
611
639
  rmSync(item.targetPath, { recursive: true, force: true });
@@ -619,13 +647,161 @@ function applyInstallPlan(plan, options) {
619
647
  } catch {
620
648
  // Ignore mode-setting failures on platforms that do not preserve POSIX modes.
621
649
  }
650
+ appliedCount += 1;
622
651
  }
623
652
 
624
653
  console.log("");
625
- console.log(`Installed ${plan.items.length} files into ${options.target}.`);
654
+ console.log(`Installed or updated ${appliedCount} files into ${options.target}.`);
655
+ if (skippedCount > 0) {
656
+ console.log(`Skipped ${skippedCount} non-blocking file(s).`);
657
+ }
658
+ if (plan.items.some((item) => item.relativePath === CODEX_HOOKS_TARGET)) {
659
+ console.log("");
660
+ console.log("Codex hook config installed or merged at .codex/hooks.json.");
661
+ console.log("To activate it, enable Codex hooks, then approve the project and hook trust prompts.");
662
+ console.log("For one session, run: codex --enable hooks");
663
+ console.log("For persistent config, set [features].hooks = true in ~/.codex/config.toml.");
664
+ }
626
665
  console.log("Recommended next step: run 'delano onboarding' to review AGENTS.md. The command asks for explicit approval before analysis.");
627
666
  }
628
667
 
668
+ function isNonBlockingExistingTarget(relativePath) {
669
+ return relativePath === CODEX_HOOKS_TARGET;
670
+ }
671
+
672
+ function applyCodexHooksConfig(item) {
673
+ const existingType = getPathType(item.targetPath);
674
+ if (!existingType) {
675
+ mkdirSync(path.dirname(item.targetPath), { recursive: true });
676
+ copyFileSync(item.sourcePath, item.targetPath);
677
+ applySourceMode(item.sourcePath, item.targetPath);
678
+ return "installed";
679
+ }
680
+
681
+ if (existingType !== "file") {
682
+ console.warn(`Skipped ${CODEX_HOOKS_TARGET}: existing ${existingType} cannot be merged safely.`);
683
+ return "skipped";
684
+ }
685
+
686
+ let existingConfig;
687
+ try {
688
+ existingConfig = readJsonFile(item.targetPath);
689
+ } catch (error) {
690
+ console.warn(`Skipped ${CODEX_HOOKS_TARGET}: existing file is not valid JSON (${error.message}).`);
691
+ return "skipped";
692
+ }
693
+
694
+ const packagedConfig = readJsonFile(item.sourcePath);
695
+ const mergeResult = mergeCodexHooksConfig(existingConfig, packagedConfig);
696
+ if (!mergeResult.ok) {
697
+ console.warn(`Skipped ${CODEX_HOOKS_TARGET}: ${mergeResult.reason}`);
698
+ return "skipped";
699
+ }
700
+
701
+ if (mergeResult.changed) {
702
+ writeFileSync(item.targetPath, `${JSON.stringify(mergeResult.config, null, 2)}\n`, "utf8");
703
+ return "merged";
704
+ }
705
+
706
+ return "unchanged";
707
+ }
708
+
709
+ function mergeCodexHooksConfig(existingConfig, packagedConfig) {
710
+ if (!isPlainObject(existingConfig)) {
711
+ return { ok: false, reason: "existing config must be a JSON object." };
712
+ }
713
+ if (!isPlainObject(packagedConfig) || !isPlainObject(packagedConfig.hooks)) {
714
+ throw new CliError("Packaged .codex/hooks.json is missing a hooks object.", 1);
715
+ }
716
+
717
+ const packagedSessionStart = packagedConfig.hooks.SessionStart;
718
+ if (!Array.isArray(packagedSessionStart)) {
719
+ throw new CliError("Packaged .codex/hooks.json is missing hooks.SessionStart.", 1);
720
+ }
721
+
722
+ const nextConfig = deepClone(existingConfig);
723
+ if (nextConfig.hooks === undefined) {
724
+ nextConfig.hooks = {};
725
+ }
726
+ if (!isPlainObject(nextConfig.hooks)) {
727
+ return { ok: false, reason: "existing hooks field must be a JSON object." };
728
+ }
729
+ if (nextConfig.hooks.SessionStart === undefined) {
730
+ nextConfig.hooks.SessionStart = [];
731
+ }
732
+ if (!Array.isArray(nextConfig.hooks.SessionStart)) {
733
+ return { ok: false, reason: "existing hooks.SessionStart field must be an array." };
734
+ }
735
+
736
+ let changed = false;
737
+ for (const desiredGroup of packagedSessionStart) {
738
+ if (!hasDelanoSessionStatusHook(nextConfig.hooks.SessionStart, desiredGroup)) {
739
+ nextConfig.hooks.SessionStart.push(deepClone(desiredGroup));
740
+ changed = true;
741
+ }
742
+ }
743
+
744
+ return {
745
+ ok: true,
746
+ changed,
747
+ config: nextConfig
748
+ };
749
+ }
750
+
751
+ function hasDelanoSessionStatusHook(sessionStartGroups, desiredGroup) {
752
+ const desiredCommands = collectHookCommands([desiredGroup]);
753
+ for (const group of sessionStartGroups) {
754
+ const commands = collectHookCommands([group]);
755
+ if (commands.some((command) => command.includes("codex-session-status.js"))) {
756
+ return true;
757
+ }
758
+ if (commands.some((command) => desiredCommands.includes(command))) {
759
+ return true;
760
+ }
761
+ }
762
+ return false;
763
+ }
764
+
765
+ function collectHookCommands(groups) {
766
+ const commands = [];
767
+ for (const group of groups) {
768
+ if (!group || !Array.isArray(group.hooks)) {
769
+ continue;
770
+ }
771
+ for (const hook of group.hooks) {
772
+ if (hook && typeof hook.command === "string") {
773
+ commands.push(hook.command);
774
+ }
775
+ }
776
+ }
777
+ return commands;
778
+ }
779
+
780
+ function applySourceMode(sourcePath, targetPath) {
781
+ const sourceMode = statSync(sourcePath).mode & 0o777;
782
+ try {
783
+ chmodSync(targetPath, sourceMode);
784
+ } catch {
785
+ // Ignore mode-setting failures on platforms that do not preserve POSIX modes.
786
+ }
787
+ }
788
+
789
+ function deepClone(value) {
790
+ return JSON.parse(JSON.stringify(value));
791
+ }
792
+
793
+ function isPlainObject(value) {
794
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
795
+ }
796
+
797
+ function readJsonFile(filePath) {
798
+ return JSON.parse(stripByteOrderMark(readFileSync(filePath, "utf8")));
799
+ }
800
+
801
+ function stripByteOrderMark(text) {
802
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
803
+ }
804
+
629
805
  function normalizeManifestEntries(rawManifest) {
630
806
  const entries = Array.isArray(rawManifest.files) ? rawManifest.files : rawManifest.paths;
631
807
  if (!Array.isArray(entries)) {
@@ -686,5 +862,6 @@ module.exports = {
686
862
  printConflicts,
687
863
  printPlanSummary,
688
864
  readInstallManifest,
689
- getMissingPackagedAssetMessage
865
+ getMissingPackagedAssetMessage,
866
+ mergeCodexHooksConfig
690
867
  };