@tyvm/knowhow 0.0.108-dev.4a8ba55 → 0.0.108-dev.99ad788

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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/chat/CliChatService.ts +3 -0
  3. package/src/cli.ts +14 -0
  4. package/src/clients/index.ts +6 -5
  5. package/src/commands/misc.ts +5 -0
  6. package/src/commands/services.ts +5 -0
  7. package/src/logger.ts +200 -0
  8. package/src/services/EventService.ts +57 -1
  9. package/src/services/modules/index.ts +13 -4
  10. package/src/services/modules/types.ts +2 -0
  11. package/tests/unit/commands/github-credentials.test.ts +211 -0
  12. package/tests/unit/modules/moduleLoading.test.ts +39 -12
  13. package/ts_build/package.json +1 -1
  14. package/ts_build/src/chat/CliChatService.js +3 -0
  15. package/ts_build/src/chat/CliChatService.js.map +1 -1
  16. package/ts_build/src/cli.js +7 -0
  17. package/ts_build/src/cli.js.map +1 -1
  18. package/ts_build/src/clients/index.js +2 -4
  19. package/ts_build/src/clients/index.js.map +1 -1
  20. package/ts_build/src/commands/misc.js +2 -0
  21. package/ts_build/src/commands/misc.js.map +1 -1
  22. package/ts_build/src/commands/services.js +2 -1
  23. package/ts_build/src/commands/services.js.map +1 -1
  24. package/ts_build/src/logger.d.ts +21 -0
  25. package/ts_build/src/logger.js +109 -0
  26. package/ts_build/src/logger.js.map +1 -0
  27. package/ts_build/src/services/EventService.d.ts +6 -1
  28. package/ts_build/src/services/EventService.js +28 -0
  29. package/ts_build/src/services/EventService.js.map +1 -1
  30. package/ts_build/src/services/modules/index.d.ts +1 -1
  31. package/ts_build/src/services/modules/index.js +7 -3
  32. package/ts_build/src/services/modules/index.js.map +1 -1
  33. package/ts_build/src/services/modules/types.d.ts +2 -0
  34. package/ts_build/tests/unit/commands/github-credentials.test.d.ts +1 -0
  35. package/ts_build/tests/unit/commands/github-credentials.test.js +146 -0
  36. package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -0
  37. package/ts_build/tests/unit/modules/moduleLoading.test.js +20 -7
  38. package/ts_build/tests/unit/modules/moduleLoading.test.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.108-dev.4a8ba55",
3
+ "version": "0.0.108-dev.99ad788",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -19,6 +19,7 @@ import editor from "@inquirer/editor";
19
19
  import fs from "fs";
20
20
  import path from "path";
21
21
  import { services } from "../services";
22
+ import { logger } from "../logger";
22
23
 
23
24
  export class CliChatService implements ChatService {
24
25
  private context: ChatContext;
@@ -267,10 +268,12 @@ export class CliChatService implements ChatService {
267
268
  } else if (this.context.multilineMode) {
268
269
  const renderer = this.context.renderer;
269
270
  if (renderer) renderer.pause();
271
+ logger.silence();
270
272
  try {
271
273
  value = await editor({ message: prompt });
272
274
  } finally {
273
275
  if (renderer) renderer.resume();
276
+ logger.unsilence();
274
277
  }
275
278
  this.context.multilineMode = false; // Disable after use like original
276
279
  } else {
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node --no-node-snapshot
2
2
  import { Command } from "commander";
3
3
  import { version } from "../package.json";
4
+ import { logger } from "./logger";
4
5
  import { migrateConfig } from "./config";
5
6
  import { getConfig, getGlobalConfig } from "./config";
6
7
  import { getEnabledPlugins } from "./types";
@@ -48,6 +49,19 @@ process.on("unhandledRejection", (reason: unknown) => {
48
49
 
49
50
  async function main() {
50
51
  const program = new Command();
52
+
53
+ // Install console overload early so ALL output (including third-party modules)
54
+ // goes through our logger closure — respects silence() for clean-stdout commands.
55
+ logger.installConsoleOverload();
56
+
57
+ // Silence immediately if this is a clean-stdout command (e.g. git credential helpers).
58
+ // Module loading happens before parseAsync, so we must silence before that point.
59
+ const rawArgs = process.argv.slice(2);
60
+ const SILENT_COMMANDS = ["github-credentials"];
61
+ if (rawArgs.some((a) => SILENT_COMMANDS.includes(a))) {
62
+ logger.silence();
63
+ }
64
+
51
65
  await migrateConfig();
52
66
  const config = await getConfig();
53
67
  const chatService = new CliChatService(getEnabledPlugins(config.plugins));
@@ -577,11 +577,12 @@ export class AIClient {
577
577
  * @param modelQuery - the model name to search for (can be partial/normalized)
578
578
  * @param provider - optional provider to restrict search to
579
579
  */
580
- findModelFuzzy(modelQuery: string, provider?: string): { provider: string; model: string } | undefined {
580
+ findModelFuzzy(
581
+ modelQuery: string,
582
+ provider?: string
583
+ ): { provider: string; model: string } | undefined {
581
584
  const queryNorm = AIClient.normalizeModelId(modelQuery);
582
- const providers = provider
583
- ? [provider]
584
- : Object.keys(this.clientModels);
585
+ const providers = provider ? [provider] : Object.keys(this.clientModels);
585
586
 
586
587
  for (const p of providers) {
587
588
  const models = (this.clientModels[p] as string[]) ?? [];
@@ -835,7 +836,7 @@ export class AIClient {
835
836
  const splitModel = m.id.split("/");
836
837
 
837
838
  if (splitModel.length < 2) {
838
- console.error(`Cannot parse model format: ${m.id}`);
839
+ console.warn(`Cannot parse model format: ${m.id}`);
839
840
  }
840
841
 
841
842
  const provider = splitModel.length > 1 ? splitModel[0] : "";
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { execSync } from "child_process";
3
3
  import { version } from "../../package.json";
4
+ import { logger } from "../logger";
4
5
  import { generate, embed, upload, download, purge } from "../index";
5
6
  import { init } from "../config";
6
7
  import { login } from "../login";
@@ -118,6 +119,10 @@ export function addGithubCredentialsCommand(program: Command): void {
118
119
  "Repository in owner/repo format (e.g. myorg/myrepo)"
119
120
  )
120
121
  .action(async (action: string | undefined, options: { repo?: string }) => {
122
+ // Silence ALL output immediately — git credential helpers must produce
123
+ // only the protocol=.../host=.../username=.../password=... lines on stdout.
124
+ logger.silence();
125
+
121
126
  const client = new KnowhowSimpleClient();
122
127
 
123
128
  let repo = options.repo;
@@ -15,8 +15,12 @@ export async function setupServices() {
15
15
  Tools: AllTools,
16
16
  Embeddings,
17
17
  Plugins,
18
+ Events,
18
19
  MediaProcessor,
19
20
  } = services();
21
+
22
+
23
+ // cli uses LazyTools to keep context slim
20
24
  const Tools = new LazyToolsService();
21
25
 
22
26
  Tools.setContext({
@@ -66,6 +70,7 @@ export async function setupServices() {
66
70
  Clients,
67
71
  Tools,
68
72
  MediaProcessor,
73
+ Events
69
74
  });
70
75
 
71
76
  return { Tools, Clients };
package/src/logger.ts ADDED
@@ -0,0 +1,200 @@
1
+ import type { LogLevel } from "./services/EventService";
2
+
3
+ /**
4
+ * App-wide logger utility.
5
+ *
6
+ * Features:
7
+ * 1. `logger.info/warn/error(source, message)` — routes through EventService
8
+ * 2. `Logger.of("ClassName")` — creates a bound logger so you don't repeat the source
9
+ * 3. `logger.installConsoleOverload()` — replaces console.log/warn/error/info with
10
+ * our closure, so ALL output (including third-party modules) goes through us
11
+ * 4. `logger.silence()` / `logger.unsilence()` — suppress all output, useful for
12
+ * commands that need clean stdout (e.g. github-credentials)
13
+ *
14
+ * Usage (module-level):
15
+ * import { logger } from "../logger";
16
+ * logger.info("MyService", "Something happened");
17
+ *
18
+ * Usage (class-level):
19
+ * import { Logger } from "../logger";
20
+ * class MyClass {
21
+ * private logger = Logger.of("MyClass");
22
+ * doThing() { this.logger.info("Something happened"); }
23
+ * }
24
+ *
25
+ * Silence mode (for clean-stdout commands):
26
+ * logger.silence(); // suppress everything
27
+ * // ... do work that must produce clean stdout ...
28
+ * logger.unsilence(); // restore
29
+ */
30
+
31
+ // ---- Internal state ---------------------------------------------------------
32
+
33
+ let silenced = false;
34
+
35
+ // Original console methods — saved before any overload is installed
36
+ const _originalConsole = {
37
+ log: console.log.bind(console),
38
+ warn: console.warn.bind(console),
39
+ error: console.error.bind(console),
40
+ info: console.info.bind(console),
41
+ };
42
+
43
+ let consoleOverloadInstalled = false;
44
+
45
+ // ---- EventService lazy accessor ---------------------------------------------
46
+
47
+ function getEvents() {
48
+ try {
49
+ const { services } = require("./services") as typeof import("./services");
50
+ return services().Events;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // ---- Core emit logic --------------------------------------------------------
57
+
58
+ function emit(source: string, message: string, level: LogLevel): void {
59
+ if (silenced) return;
60
+
61
+ try {
62
+ const events = getEvents();
63
+ if (events) {
64
+ events.log(source, message, level);
65
+ return;
66
+ }
67
+ } catch {
68
+ // fall through to direct console output
69
+ }
70
+
71
+ // Fallback: use original console methods (bypasses any overload we installed)
72
+ const prefix = source ? `[${source}] ` : "";
73
+ if (level === "warn") _originalConsole.warn(`${prefix}${message}`);
74
+ else if (level === "error") _originalConsole.error(`${prefix}${message}`);
75
+ else _originalConsole.log(`${prefix}${message}`);
76
+ }
77
+
78
+ // ---- Bound logger (returned by Logger.of) -----------------------------------
79
+
80
+ export interface BoundLogger {
81
+ log(message: string, level?: LogLevel): void;
82
+ info(message: string): void;
83
+ warn(message: string): void;
84
+ error(message: string): void;
85
+ }
86
+
87
+ function makeBoundLogger(source: string): BoundLogger {
88
+ return {
89
+ log(message: string, level: LogLevel = "info"): void {
90
+ emit(source, message, level);
91
+ },
92
+ info(message: string): void {
93
+ emit(source, message, "info");
94
+ },
95
+ warn(message: string): void {
96
+ emit(source, message, "warn");
97
+ },
98
+ error(message: string): void {
99
+ emit(source, message, "error");
100
+ },
101
+ };
102
+ }
103
+
104
+ // ---- Public API -------------------------------------------------------------
105
+
106
+ export const logger = {
107
+ log(source: string, message: string, level: LogLevel = "info"): void {
108
+ emit(source, message, level);
109
+ },
110
+
111
+ info(source: string, message: string): void {
112
+ emit(source, message, "info");
113
+ },
114
+
115
+ warn(source: string, message: string): void {
116
+ emit(source, message, "warn");
117
+ },
118
+
119
+ error(source: string, message: string): void {
120
+ emit(source, message, "error");
121
+ },
122
+
123
+ /**
124
+ * Suppress all log output. Useful for commands that need clean stdout
125
+ * (e.g. git credential helpers). All logger.* calls and overloaded
126
+ * console.* calls become no-ops until unsilence() is called.
127
+ */
128
+ silence(): void {
129
+ silenced = true;
130
+ },
131
+
132
+ /**
133
+ * Restore log output after a silence() call.
134
+ */
135
+ unsilence(): void {
136
+ silenced = false;
137
+ },
138
+
139
+ /**
140
+ * Returns true if the logger is currently silenced.
141
+ */
142
+ isSilenced(): boolean {
143
+ return silenced;
144
+ },
145
+
146
+ /**
147
+ * Install console overload. After this call, console.log/warn/error/info
148
+ * all route through our closure (respecting silence mode).
149
+ * Safe to call multiple times — only installs once.
150
+ *
151
+ * Call this early in CLI startup (before any modules are loaded) to ensure
152
+ * third-party module logs don't bypass the silence mechanism.
153
+ */
154
+ installConsoleOverload(): void {
155
+ if (consoleOverloadInstalled) return;
156
+ consoleOverloadInstalled = true;
157
+
158
+ const route = (level: LogLevel, args: any[]) => {
159
+ if (silenced) return;
160
+ const message = args
161
+ .map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
162
+ .join(" ");
163
+ emit("", message, level);
164
+ };
165
+
166
+ console.log = (...args: any[]) => route("info", args);
167
+ console.info = (...args: any[]) => route("info", args);
168
+ console.warn = (...args: any[]) => route("warn", args);
169
+ // Note: console.error is intentionally NOT overloaded — real errors (stack
170
+ // traces, crash reports) should always be visible. Only suppress via silence().
171
+ // If you want to suppress errors too, call logger.silence() which checks the flag
172
+ // before the overloaded console.warn/log routes reach here anyway.
173
+ },
174
+
175
+ /**
176
+ * Remove the console overload and restore original console methods.
177
+ */
178
+ uninstallConsoleOverload(): void {
179
+ if (!consoleOverloadInstalled) return;
180
+ console.log = _originalConsole.log;
181
+ console.info = _originalConsole.info;
182
+ console.warn = _originalConsole.warn;
183
+ consoleOverloadInstalled = false;
184
+ },
185
+ };
186
+
187
+ /**
188
+ * Factory for creating a bound logger with a fixed source name.
189
+ * Ideal for class-level loggers:
190
+ *
191
+ * class MyClass {
192
+ * private logger = Logger.of("MyClass");
193
+ * doThing() { this.logger.info("hello"); }
194
+ * }
195
+ */
196
+ export const Logger = {
197
+ of(source: string): BoundLogger {
198
+ return makeBoundLogger(source);
199
+ },
200
+ };
@@ -1,6 +1,8 @@
1
1
  import { EventEmitter } from "events";
2
2
  import { IAgent } from "../agents/interface";
3
3
 
4
+ export type LogLevel = "info" | "warn" | "error";
5
+
4
6
  export type EventHandlerFn = (...args: any[]) => any;
5
7
 
6
8
  export interface EventHandler {
@@ -31,9 +33,34 @@ type ManagedListenerRecord = {
31
33
  blocking: boolean;
32
34
  };
33
35
 
36
+ /**
37
+ * Default console handler for plugin:log events.
38
+ * Active when no renderer has taken over (e.g. worker mode, CLI before chat starts).
39
+ * Can be suppressed by calling suppressDefaultLogger() when a renderer is active.
40
+ */
41
+ function defaultConsoleLogHandler(event: {
42
+ source: string;
43
+ message: string;
44
+ level: LogLevel;
45
+ }): void {
46
+ const prefix = event.source ? `[${event.source}] ` : "";
47
+ switch (event.level) {
48
+ case "warn":
49
+ console.warn(`${prefix}${event.message}`);
50
+ break;
51
+ case "error":
52
+ console.error(`${prefix}${event.message}`);
53
+ break;
54
+ default:
55
+ console.log(`${prefix}${event.message}`);
56
+ }
57
+ }
58
+
34
59
  export class EventService extends EventEmitter {
35
60
  private blockingHandlers: Map<string, EventHandler[]> = new Map();
36
61
  private managedListeners: Map<string, ManagedListenerRecord> = new Map();
62
+ private defaultLoggerActive = true;
63
+ private boundDefaultLogHandler = defaultConsoleLogHandler;
37
64
 
38
65
  eventTypes = {
39
66
  agentMsg: "agent:msg",
@@ -45,6 +72,35 @@ export class EventService extends EventEmitter {
45
72
  constructor() {
46
73
  super();
47
74
  this.setMaxListeners(100);
75
+ // Register the default console logger so Events.log() always produces output
76
+ // even before a renderer is attached (worker mode, module loading, etc.)
77
+ this.on(this.eventTypes.pluginLog, this.boundDefaultLogHandler);
78
+ }
79
+
80
+ /**
81
+ * Suppress the default console logger.
82
+ * Call this when a renderer has taken over and will handle plugin:log events.
83
+ * This prevents double-printing when both the renderer and the default handler fire.
84
+ */
85
+ suppressDefaultLogger(): void {
86
+ if (this.defaultLoggerActive) {
87
+ this.removeListener(
88
+ this.eventTypes.pluginLog,
89
+ this.boundDefaultLogHandler
90
+ );
91
+ this.defaultLoggerActive = false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Restore the default console logger.
97
+ * Call this when the renderer is torn down.
98
+ */
99
+ restoreDefaultLogger(): void {
100
+ if (!this.defaultLoggerActive) {
101
+ this.on(this.eventTypes.pluginLog, this.boundDefaultLogHandler);
102
+ this.defaultLoggerActive = true;
103
+ }
48
104
  }
49
105
 
50
106
  /**
@@ -232,7 +288,7 @@ export class EventService extends EventEmitter {
232
288
  log(
233
289
  source: string,
234
290
  message: string,
235
- level: "info" | "warn" | "error" = "info"
291
+ level: LogLevel = "info"
236
292
  ): void {
237
293
  this.emit(this.eventTypes.pluginLog, {
238
294
  source,
@@ -33,11 +33,17 @@ export class ModulesService {
33
33
  : modulePath;
34
34
  const rawModule = require(resolvedPath);
35
35
  const importedModule = (rawModule.default || rawModule) as KnowhowModule;
36
- console.log(
36
+ context.Events?.log(
37
+ "ModulesService",
37
38
  `🔌 Loading module: ${modulePath} (resolved: ${resolvedPath})`
38
39
  );
39
- await importedModule.init({ config, cwd: process.cwd(), context: context as ModuleContext });
40
- console.log(
40
+ await importedModule.init({
41
+ config,
42
+ cwd: process.cwd(),
43
+ context: context as ModuleContext,
44
+ });
45
+ context.Events?.log(
46
+ "ModulesService",
41
47
  `✅ Module initialized: ${modulePath} (tools: ${importedModule.tools.length}, agents: ${importedModule.agents.length}, plugins: ${importedModule.plugins.length}, clients: ${importedModule.clients.length})`
42
48
  );
43
49
 
@@ -52,7 +58,10 @@ export class ModulesService {
52
58
  if (context.Tools) {
53
59
  for (const tool of importedModule.tools) {
54
60
  context.Tools.addTool(tool.definition);
55
- context.Tools.setFunction(tool.definition.function.name, tool.handler);
61
+ context.Tools.setFunction(
62
+ tool.definition.function.name,
63
+ tool.handler
64
+ );
56
65
  }
57
66
  }
58
67
 
@@ -11,6 +11,7 @@ import { AIClient } from "../../clients";
11
11
  import { ToolsService } from "../Tools";
12
12
  import { MediaProcessorService } from "../MediaProcessorService";
13
13
  import { TunnelHandler } from "@tyvm/knowhow-tunnel";
14
+ import { EventService } from "../EventService";
14
15
 
15
16
  /*
16
17
  *
@@ -54,6 +55,7 @@ export interface ModuleContext {
54
55
  Plugins: PluginService;
55
56
  Clients: AIClient;
56
57
  Tools: ToolsService;
58
+ Events: EventService;
57
59
  MediaProcessor?: MediaProcessorService;
58
60
  Tunnel?: TunnelHandler;
59
61
  Program?: Command;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Unit tests for the github-credentials command.
3
+ *
4
+ * Key invariant: running `github-credentials` must NEVER write anything other than
5
+ * the credential lines to stdout. Module loading logs, warnings, etc. must be
6
+ * silenced so the git credential helper protocol is not corrupted.
7
+ */
8
+
9
+ // Mock config before any imports that depend on it
10
+ jest.mock("../../../src/config", () => ({
11
+ getConfig: jest.fn().mockResolvedValue({ modules: [] }),
12
+ getGlobalConfig: jest.fn().mockResolvedValue({ modules: [] }),
13
+ getConfigSync: jest.fn().mockReturnValue({}),
14
+ migrateConfig: jest.fn().mockResolvedValue(undefined),
15
+ }));
16
+
17
+ // Mock clients to avoid openai.ts side-effects
18
+ jest.mock("../../../src/clients", () => ({
19
+ AIClient: jest.fn(),
20
+ Clients: { registerClient: jest.fn(), registerModels: jest.fn() },
21
+ }));
22
+
23
+ // Mock KnowhowSimpleClient so we control what getGitCredential returns
24
+ // without needing a real JWT or network connection
25
+ jest.mock("../../../src/services/KnowhowClient", () => ({
26
+ KnowhowSimpleClient: jest.fn().mockImplementation(() => ({
27
+ getGitCredential: jest.fn().mockResolvedValue({
28
+ protocol: "https",
29
+ host: "github.com",
30
+ username: "x-access-token",
31
+ password: "ghu_TESTTOKEN123",
32
+ }),
33
+ })),
34
+ }));
35
+
36
+ // Mock readline so the 'get' action doesn't hang waiting for stdin
37
+ jest.mock("readline", () => ({
38
+ createInterface: jest.fn().mockReturnValue({
39
+ on: jest.fn().mockImplementation(function (event: string, cb: Function) {
40
+ // Immediately fire 'close' so the readline promise resolves
41
+ if (event === "close") {
42
+ setImmediate(() => cb());
43
+ }
44
+ return this;
45
+ }),
46
+ }),
47
+ }));
48
+
49
+ import { Command } from "commander";
50
+ import { addGithubCredentialsCommand } from "../../../src/commands/misc";
51
+ import { logger } from "../../../src/logger";
52
+
53
+ describe("github-credentials command", () => {
54
+ /**
55
+ * This test verifies the EARLY silencing logic in cli.ts main().
56
+ * The problem: modules load BEFORE parseAsync, so any module that emits
57
+ * warnings (e.g. Terminal module: no TunnelHandler) does so before the
58
+ * action's logger.silence() call can stop it.
59
+ *
60
+ * The fix: cli.ts checks process.argv before module loading and silences early.
61
+ * This test simulates that logic directly.
62
+ */
63
+ describe("early silencing (pre-module-load)", () => {
64
+ beforeEach(() => {
65
+ logger.unsilence();
66
+ logger.installConsoleOverload();
67
+ });
68
+
69
+ afterEach(() => {
70
+ logger.unsilence();
71
+ logger.uninstallConsoleOverload();
72
+ });
73
+
74
+ it("silences before module loading when github-credentials is in argv", () => {
75
+ const originalArgv = process.argv;
76
+ process.argv = ["node", "knowhow", "github-credentials", "get"];
77
+
78
+ // Simulate the exact early-detection logic from cli.ts main()
79
+ const rawArgs = process.argv.slice(2);
80
+ const SILENT_COMMANDS = ["github-credentials"];
81
+ if (rawArgs.some((a) => SILENT_COMMANDS.includes(a))) {
82
+ logger.silence();
83
+ }
84
+
85
+ // Now any module-load-time console.log/warn should be suppressed
86
+ const consoleSpy = jest.spyOn(process.stdout, "write");
87
+ console.warn("⚠️ Terminal module: no TunnelHandler in context — terminal addon not registered");
88
+ console.log("some other module loading noise");
89
+
90
+ expect(consoleSpy).not.toHaveBeenCalled();
91
+ consoleSpy.mockRestore();
92
+ process.argv = originalArgv;
93
+ });
94
+
95
+ it("does NOT silence for other commands", () => {
96
+ const originalArgv = process.argv;
97
+ process.argv = ["node", "knowhow", "chat"];
98
+
99
+ const rawArgs = process.argv.slice(2);
100
+ const SILENT_COMMANDS = ["github-credentials"];
101
+ if (rawArgs.some((a) => SILENT_COMMANDS.includes(a))) {
102
+ logger.silence();
103
+ }
104
+
105
+ expect(logger.isSilenced()).toBe(false);
106
+ process.argv = originalArgv;
107
+ });
108
+ });
109
+
110
+ let program: Command;
111
+ let stdoutSpy: jest.SpyInstance;
112
+ let writtenToStdout: string[];
113
+
114
+ beforeEach(() => {
115
+ jest.clearAllMocks();
116
+
117
+ // Reset logger silence state between tests
118
+ logger.unsilence();
119
+
120
+ // Capture process.stdout.write — this is what the credential helper uses
121
+ writtenToStdout = [];
122
+ stdoutSpy = jest
123
+ .spyOn(process.stdout, "write")
124
+ .mockImplementation((chunk: any) => {
125
+ writtenToStdout.push(typeof chunk === "string" ? chunk : chunk.toString());
126
+ return true;
127
+ });
128
+
129
+ program = new Command();
130
+ program.exitOverride(); // prevent process.exit during tests
131
+ addGithubCredentialsCommand(program);
132
+ });
133
+
134
+ afterEach(() => {
135
+ stdoutSpy.mockRestore();
136
+ logger.unsilence();
137
+ });
138
+
139
+ it("outputs only credential lines to stdout for 'get' action", async () => {
140
+ await program.parseAsync([
141
+ "node", "knowhow", "github-credentials", "get", "--repo", "myorg/myrepo",
142
+ ]);
143
+
144
+ expect(writtenToStdout).toHaveLength(1);
145
+ expect(writtenToStdout[0]).toBe(
146
+ "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghu_TESTTOKEN123\n"
147
+ );
148
+ });
149
+
150
+ it("silences the logger immediately so module logs don't pollute stdout", async () => {
151
+ await program.parseAsync([
152
+ "node", "knowhow", "github-credentials", "get", "--repo", "myorg/myrepo",
153
+ ]);
154
+
155
+ // The action must have called logger.silence() — state persists after action
156
+ expect(logger.isSilenced()).toBe(true);
157
+ });
158
+
159
+ it("produces exactly 4 credential field lines and nothing else", async () => {
160
+ await program.parseAsync([
161
+ "node", "knowhow", "github-credentials", "get", "--repo", "myorg/myrepo",
162
+ ]);
163
+
164
+ const allOutput = writtenToStdout.join("");
165
+ const lines = allOutput.trim().split("\n");
166
+
167
+ expect(lines).toHaveLength(4);
168
+ expect(lines[0]).toMatch(/^protocol=/);
169
+ expect(lines[1]).toMatch(/^host=/);
170
+ expect(lines[2]).toMatch(/^username=/);
171
+ expect(lines[3]).toMatch(/^password=/);
172
+ });
173
+
174
+ it("exits cleanly for 'store' action without writing credentials", async () => {
175
+ let exitCode: number | undefined;
176
+ // Throw to stop execution after exit() is called — otherwise the mock
177
+ // just sets a flag and the action continues to fetch credentials.
178
+ const exitSpy = jest
179
+ .spyOn(process, "exit")
180
+ .mockImplementation(((code?: number) => {
181
+ exitCode = code ?? 0;
182
+ throw new Error(`process.exit(${exitCode})`);
183
+ }) as any);
184
+
185
+ await expect(
186
+ program.parseAsync(["node", "knowhow", "github-credentials", "store"])
187
+ ).rejects.toThrow("process.exit(0)");
188
+
189
+ expect(exitCode).toBe(0);
190
+ expect(writtenToStdout).toHaveLength(0);
191
+ exitSpy.mockRestore();
192
+ });
193
+
194
+ it("exits cleanly for 'erase' action without writing credentials", async () => {
195
+ let exitCode: number | undefined;
196
+ const exitSpy = jest
197
+ .spyOn(process, "exit")
198
+ .mockImplementation(((code?: number) => {
199
+ exitCode = code ?? 0;
200
+ throw new Error(`process.exit(${exitCode})`);
201
+ }) as any);
202
+
203
+ await expect(
204
+ program.parseAsync(["node", "knowhow", "github-credentials", "erase"])
205
+ ).rejects.toThrow("process.exit(0)");
206
+
207
+ expect(exitCode).toBe(0);
208
+ expect(writtenToStdout).toHaveLength(0);
209
+ exitSpy.mockRestore();
210
+ });
211
+ });