@yeseh/cortex-cli 0.6.8 → 0.6.9

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 (74) hide show
  1. package/dist/program.js +1538 -5
  2. package/dist/program.js.map +32 -3
  3. package/dist/run.d.ts +0 -1
  4. package/dist/run.d.ts.map +1 -1
  5. package/dist/run.js +3 -4
  6. package/dist/run.js.map +3 -3
  7. package/package.json +4 -6
  8. package/dist/chunk-dsfj4baj.js +0 -1543
  9. package/dist/chunk-dsfj4baj.js.map +0 -38
  10. package/src/category/commands/create.spec.ts +0 -139
  11. package/src/category/commands/create.ts +0 -119
  12. package/src/category/index.ts +0 -24
  13. package/src/commands/init.spec.ts +0 -203
  14. package/src/commands/init.ts +0 -301
  15. package/src/context.spec.ts +0 -60
  16. package/src/context.ts +0 -170
  17. package/src/errors.spec.ts +0 -264
  18. package/src/errors.ts +0 -105
  19. package/src/memory/commands/add.spec.ts +0 -169
  20. package/src/memory/commands/add.ts +0 -158
  21. package/src/memory/commands/definitions.spec.ts +0 -80
  22. package/src/memory/commands/list.spec.ts +0 -123
  23. package/src/memory/commands/list.ts +0 -269
  24. package/src/memory/commands/move.spec.ts +0 -85
  25. package/src/memory/commands/move.ts +0 -119
  26. package/src/memory/commands/remove.spec.ts +0 -79
  27. package/src/memory/commands/remove.ts +0 -108
  28. package/src/memory/commands/show.spec.ts +0 -71
  29. package/src/memory/commands/show.ts +0 -165
  30. package/src/memory/commands/test-helpers.spec.ts +0 -127
  31. package/src/memory/commands/update.spec.ts +0 -86
  32. package/src/memory/commands/update.ts +0 -230
  33. package/src/memory/index.spec.ts +0 -59
  34. package/src/memory/index.ts +0 -44
  35. package/src/memory/parsing.spec.ts +0 -105
  36. package/src/memory/parsing.ts +0 -22
  37. package/src/observability.spec.ts +0 -126
  38. package/src/observability.ts +0 -82
  39. package/src/output.spec.ts +0 -835
  40. package/src/output.ts +0 -119
  41. package/src/program.spec.ts +0 -46
  42. package/src/program.ts +0 -75
  43. package/src/run.spec.ts +0 -31
  44. package/src/run.ts +0 -9
  45. package/src/store/commands/add.spec.ts +0 -131
  46. package/src/store/commands/add.ts +0 -231
  47. package/src/store/commands/init.spec.ts +0 -220
  48. package/src/store/commands/init.ts +0 -272
  49. package/src/store/commands/list.spec.ts +0 -175
  50. package/src/store/commands/list.ts +0 -102
  51. package/src/store/commands/prune.spec.ts +0 -120
  52. package/src/store/commands/prune.ts +0 -152
  53. package/src/store/commands/reindexs.spec.ts +0 -94
  54. package/src/store/commands/reindexs.ts +0 -97
  55. package/src/store/commands/remove.spec.ts +0 -97
  56. package/src/store/commands/remove.ts +0 -189
  57. package/src/store/index.spec.ts +0 -60
  58. package/src/store/index.ts +0 -49
  59. package/src/store/utils/resolve-store-name.spec.ts +0 -62
  60. package/src/store/utils/resolve-store-name.ts +0 -79
  61. package/src/test-helpers.spec.ts +0 -430
  62. package/src/tests/cli.integration.spec.ts +0 -1306
  63. package/src/toon.spec.ts +0 -183
  64. package/src/toon.ts +0 -462
  65. package/src/utils/git.spec.ts +0 -95
  66. package/src/utils/git.ts +0 -51
  67. package/src/utils/input.spec.ts +0 -326
  68. package/src/utils/input.ts +0 -150
  69. package/src/utils/paths.spec.ts +0 -235
  70. package/src/utils/paths.ts +0 -75
  71. package/src/utils/prompts.spec.ts +0 -23
  72. package/src/utils/prompts.ts +0 -88
  73. package/src/utils/resolve-default-store.spec.ts +0 -135
  74. package/src/utils/resolve-default-store.ts +0 -74
@@ -1,220 +0,0 @@
1
- /**
2
- * Unit tests for the store init command handler.
3
- *
4
- * @module cli/store/commands/init.spec
5
- */
6
-
7
- import { describe, it, expect, afterEach, beforeEach, mock } from 'bun:test';
8
- import { InvalidArgumentError } from '@commander-js/extra-typings';
9
- import { mkdtemp, rm } from 'node:fs/promises';
10
- import { tmpdir } from 'node:os';
11
- import { join } from 'node:path';
12
- import type { PromptDeps } from '../../utils/prompts.ts';
13
-
14
- import { handleInit } from './init.ts';
15
- import { createMockContext, captureOutput } from '../../test-helpers.spec.ts';
16
- import { FilesystemConfigAdapter } from '@yeseh/cortex-storage-fs';
17
-
18
- /**
19
- * Creates a real FilesystemConfigAdapter backed by a temp config file and
20
- * injects it into a mock context so handleInit can construct a real
21
- * FilesystemStorageAdapter without the adapter factory throwing STORE_NOT_FOUND.
22
- */
23
- async function createInitContext(tempDir: string) {
24
- const configPath = join(tempDir, 'config.yaml');
25
- const configAdapter = new FilesystemConfigAdapter(configPath);
26
- await configAdapter.initializeConfig();
27
-
28
- const { ctx, stdout, stdin } = createMockContext({ cwd: tempDir });
29
- // Replace the mock ConfigAdapter with a real one so FilesystemStorageAdapter
30
- // construction inside handleInit works correctly.
31
- (ctx as unknown as Record<string, unknown>).config = configAdapter;
32
-
33
- return { ctx, stdout, stdin };
34
- }
35
-
36
- describe('handleInit', () => {
37
- let tempDir: string;
38
-
39
- beforeEach(async () => {
40
- tempDir = await mkdtemp(join(tmpdir(), 'cortex-init-'));
41
- });
42
-
43
- afterEach(async () => {
44
- await rm(tempDir, { recursive: true, force: true });
45
- });
46
-
47
- it('should initialize a store and write success message to stdout', async () => {
48
- const { ctx, stdout } = await createInitContext(tempDir);
49
-
50
- await handleInit(ctx, undefined, { name: 'my-project', format: 'yaml' });
51
-
52
- const out = captureOutput(stdout);
53
- expect(out).toContain('my-project');
54
- });
55
-
56
- it('should use the provided target path as the store path', async () => {
57
- const customPath = join(tempDir, 'custom-store');
58
- const { ctx, stdout } = await createInitContext(tempDir);
59
-
60
- await handleInit(ctx, customPath, { name: 'my-project', format: 'yaml' });
61
-
62
- const out = captureOutput(stdout);
63
- expect(out).toContain('custom-store');
64
- });
65
-
66
- it('should default to .cortex under cwd when no target path is given', async () => {
67
- const { ctx, stdout } = await createInitContext(tempDir);
68
-
69
- await handleInit(ctx, undefined, { name: 'my-project', format: 'yaml' });
70
-
71
- const out = captureOutput(stdout);
72
- // Default path is <cwd>/.cortex
73
- expect(out).toContain('.cortex');
74
- });
75
-
76
- it('should throw InvalidArgumentError for an invalid store name', async () => {
77
- const { ctx } = await createInitContext(tempDir);
78
-
79
- await expect(handleInit(ctx, undefined, { name: ' ', format: 'yaml' })).rejects.toThrow(
80
- InvalidArgumentError,
81
- );
82
- });
83
-
84
- it('should output in JSON format when format option is json', async () => {
85
- const { ctx, stdout } = await createInitContext(tempDir);
86
-
87
- await handleInit(ctx, undefined, { name: 'my-project', format: 'json' });
88
-
89
- const out = captureOutput(stdout);
90
- const parsed = JSON.parse(out) as { value: Record<string, unknown> };
91
- expect(typeof parsed.value.name).toBe('string');
92
- expect(typeof parsed.value.path).toBe('string');
93
- });
94
-
95
- it('should include the store name in the success output', async () => {
96
- const { ctx, stdout } = await createInitContext(tempDir);
97
-
98
- await handleInit(ctx, undefined, { name: 'hello-world', format: 'json' });
99
-
100
- const out = captureOutput(stdout);
101
- const parsed = JSON.parse(out) as { value: { name: string } };
102
- expect(parsed.value.name).toBe('hello-world');
103
- });
104
-
105
- it('should expand tilde in target path', async () => {
106
- const { ctx, stdout } = await createInitContext(tempDir);
107
-
108
- // resolveUserPath expands ~ — we just verify it doesn't throw and
109
- // produces output with a home-like absolute path
110
- await handleInit(ctx, '~/my-store', { name: 'my-project', format: 'yaml' });
111
-
112
- const out = captureOutput(stdout);
113
- expect(out).toContain('my-store');
114
- });
115
-
116
- it('should fail when the store name already exists', async () => {
117
- const { ctx } = await createInitContext(tempDir);
118
- const storePath = join(tempDir, '.cortex');
119
-
120
- await handleInit(ctx, storePath, { name: 'duplicate-store', format: 'yaml' });
121
-
122
- // Second init with the same name should throw
123
- const { ctx: ctx2 } = await createInitContext(tempDir);
124
- await expect(
125
- handleInit(ctx2, join(tempDir, '.cortex2'), { name: 'duplicate-store', format: 'yaml' }),
126
- ).rejects.toThrow();
127
- });
128
- });
129
-
130
- describe('handleInit - interactive mode', () => {
131
- let tempDir: string;
132
-
133
- beforeEach(async () => {
134
- tempDir = await mkdtemp(join(tmpdir(), 'cortex-init-interactive-'));
135
- });
136
-
137
- afterEach(async () => {
138
- await rm(tempDir, { recursive: true, force: true });
139
- });
140
-
141
- it('should call promptDeps.input once (path only) when stdin is a TTY and --name is explicitly given', async () => {
142
- const inputMock = mock(
143
- async ({ default: d }: { message: string; default?: string }) => d ?? 'prompted-value',
144
- );
145
- const promptDeps: PromptDeps = { input: inputMock, confirm: mock(async () => true) };
146
-
147
- const { ctx, stdin } = await createInitContext(tempDir);
148
- (stdin as unknown as { isTTY: boolean }).isTTY = true;
149
-
150
- await handleInit(ctx, undefined, { name: 'my-project', format: 'yaml' }, promptDeps);
151
-
152
- // Should NOT call input when --name is explicitly given (skips name prompt but still prompts path)
153
- // With explicit name, only path prompt is called
154
- expect(inputMock).toHaveBeenCalledTimes(1);
155
- });
156
-
157
- it('should call promptDeps.input twice (name + path) when stdin is a TTY and no --name given', async () => {
158
- const inputMock = mock(
159
- async ({ default: d }: { message: string; default?: string }) => d ?? 'prompted-value',
160
- );
161
- const promptDeps: PromptDeps = { input: inputMock, confirm: mock(async () => true) };
162
-
163
- const { ctx, stdin } = await createInitContext(tempDir);
164
- (stdin as unknown as { isTTY: boolean }).isTTY = true;
165
-
166
- // No --name provided, should prompt for both name and path
167
- await handleInit(ctx, undefined, { format: 'yaml' }, promptDeps);
168
-
169
- expect(inputMock).toHaveBeenCalledTimes(2);
170
- });
171
-
172
- it('should NOT call promptDeps.input when stdin is NOT a TTY', async () => {
173
- const inputMock = mock(
174
- async ({ default: d }: { message: string; default?: string }) => d ?? 'prompted-value',
175
- );
176
- const promptDeps: PromptDeps = { input: inputMock, confirm: mock(async () => true) };
177
-
178
- const { ctx } = await createInitContext(tempDir);
179
- // ctx stdin has isTTY = undefined (non-TTY)
180
-
181
- await handleInit(ctx, undefined, { name: 'my-project', format: 'yaml' }, promptDeps);
182
-
183
- expect(inputMock).not.toHaveBeenCalled();
184
- });
185
-
186
- it('should skip both prompts when --name and target path are both given explicitly and TTY', async () => {
187
- const inputMock = mock(
188
- async ({ default: d }: { message: string; default?: string }) => d ?? 'prompted-value',
189
- );
190
- const promptDeps: PromptDeps = { input: inputMock, confirm: mock(async () => true) };
191
-
192
- const { ctx, stdin } = await createInitContext(tempDir);
193
- (stdin as unknown as { isTTY: boolean }).isTTY = true;
194
- const customPath = join(tempDir, 'custom-store');
195
-
196
- await handleInit(ctx, customPath, { name: 'my-project', format: 'yaml' }, promptDeps);
197
-
198
- expect(inputMock).not.toHaveBeenCalled();
199
- });
200
-
201
- it('should use prompted store name in the output', async () => {
202
- const promptedName = 'prompted-store-name';
203
- const inputMock = mock(
204
- async ({ message, default: d }: { message: string; default?: string }) => {
205
- if (message.toLowerCase().includes('name')) return promptedName;
206
- return d ?? 'default-path';
207
- },
208
- );
209
- const promptDeps: PromptDeps = { input: inputMock, confirm: mock(async () => true) };
210
-
211
- const { ctx, stdin, stdout } = await createInitContext(tempDir);
212
- (stdin as unknown as { isTTY: boolean }).isTTY = true;
213
-
214
- await handleInit(ctx, undefined, { format: 'json' }, promptDeps);
215
-
216
- const out = captureOutput(stdout);
217
- const parsed = JSON.parse(out) as { value: { name: string } };
218
- expect(parsed.value.name).toBe(promptedName);
219
- });
220
- });
@@ -1,272 +0,0 @@
1
- /**
2
- * Store init command for initializing a new memory store.
3
- *
4
- * This command creates a new memory store at the specified path (or current
5
- * directory) and registers it in the global registry. The store name is
6
- * either explicitly provided via --name or auto-detected from the git
7
- * repository name.
8
- *
9
- * @example
10
- * ```bash
11
- * # Initialize store with auto-detected name from git repo
12
- * cortex store init
13
- *
14
- * # Initialize store with explicit name
15
- * cortex store init --name my-project
16
- *
17
- * # Initialize store at a specific path
18
- * cortex store init ./my-store --name my-project
19
- *
20
- * # Initialize store with tilde expansion
21
- * cortex store init ~/memories --name personal
22
- * ```
23
- */
24
-
25
- import { Command } from '@commander-js/extra-typings';
26
- import { resolve } from 'node:path';
27
- import { stdin, stdout as processStdout } from 'node:process';
28
- import { resolveStoreName } from '../utils/resolve-store-name.ts';
29
- import { throwCliError } from '../../errors.ts';
30
- import { type StoreData, initializeStore } from '@yeseh/cortex-core/store';
31
- import { type CategoryMode, type CortexContext } from '@yeseh/cortex-core';
32
- import { serializeOutput, type OutputStoreInit, type OutputFormat } from '../../output.ts';
33
- import { resolveUserPath } from '../../utils/paths.ts';
34
- import { createCliConfigContext } from '../../context.ts';
35
- import { isTTY, defaultPromptDeps, type PromptDeps } from '../../utils/prompts.ts';
36
- import type { FilesystemConfigAdapter } from '@yeseh/cortex-storage-fs';
37
- import { FilesystemStorageAdapter } from '@yeseh/cortex-storage-fs';
38
-
39
- /**
40
- * Options for the init command.
41
- */
42
- export interface InitCommandOptions {
43
- /** Explicit store name (otherwise auto-detected from git) */
44
- name?: string;
45
- /** Category mode for the store */
46
- categoryMode?: CategoryMode;
47
- /** Output format (yaml, json, toon) */
48
- format?: string;
49
- /** Optional description for the store */
50
- description?: string;
51
- }
52
-
53
- /**
54
- * Writes the serialized output to the output stream.
55
- *
56
- * @param output - The store init output payload
57
- * @param format - The output format
58
- * @param stdout - The output stream
59
- */
60
- function writeOutput(
61
- output: OutputStoreInit,
62
- format: OutputFormat,
63
- stdout: NodeJS.WritableStream,
64
- ): void {
65
- const serialized = serializeOutput({ kind: 'store-init', value: output }, format);
66
- if (!serialized.ok()) {
67
- throwCliError({ code: 'SERIALIZE_FAILED', message: serialized.error.message });
68
- }
69
- stdout.write(serialized.value + '\n');
70
- }
71
-
72
- /**
73
- * Prompts the user to confirm or change the resolved store name and path.
74
- *
75
- * Prompts are selectively skipped:
76
- * - Name prompt skipped when `explicit.name` is provided
77
- * - Path prompt skipped when `explicit.path` is provided
78
- * - All prompts skipped when stdin is not a TTY
79
- *
80
- * @param ctx - Cortex context used for TTY detection via `ctx.stdin`
81
- * @param resolved - Default store name and path to present as suggestions
82
- * @param explicit - Which values were provided explicitly by the user
83
- * @param promptDeps - Injectable prompt functions for testability
84
- * @returns Finalized store name and path
85
- */
86
- async function promptStoreInitOptions(
87
- ctx: CortexContext,
88
- resolved: { storeName: string; storePath: string },
89
- explicit: { name?: string; path?: string },
90
- promptDeps: PromptDeps,
91
- ): Promise<{ storeName: string; storePath: string }> {
92
- if (!isTTY(ctx.stdin)) return resolved;
93
-
94
- const storeName = explicit.name
95
- ? resolved.storeName
96
- : await promptDeps.input({ message: 'Store name:', default: resolved.storeName });
97
-
98
- let storePath: string;
99
- if (explicit.path) {
100
- storePath = resolved.storePath;
101
- }
102
- else {
103
- const promptedPath = await promptDeps.input({
104
- message: 'Store path:',
105
- default: resolved.storePath,
106
- });
107
- const trimmedPath = promptedPath.trim();
108
- storePath = resolveUserPath(trimmedPath, process.cwd());
109
- }
110
-
111
- return { storeName, storePath };
112
- }
113
-
114
- /**
115
- * Tries to resolve the store name. Returns an empty string when resolution
116
- * fails and stdin is a TTY (allowing the interactive prompt to take over).
117
- * Re-throws when stdin is not a TTY.
118
- *
119
- * @param cwd - Current working directory for git detection
120
- * @param explicitName - Optional explicit name from `--name` flag
121
- * @param tty - Whether stdin is a TTY
122
- */
123
- async function resolveStoreNameOrEmpty(
124
- cwd: string,
125
- explicitName: string | undefined,
126
- tty: boolean,
127
- ): Promise<string> {
128
- try {
129
- return await resolveStoreName(cwd, explicitName);
130
- }
131
- catch (e) {
132
- // When running in a TTY, only swallow errors (and fall back to prompting)
133
- // if no explicit name was provided. If the user passed an explicit --name,
134
- // re-throw so they see the actual invalid-name error.
135
- if (tty && !explicitName) return '';
136
- throw e;
137
- }
138
- }
139
-
140
- /**
141
- * Handles the store init command execution.
142
- *
143
- * This function:
144
- * 1. Resolves the store name (explicit or git detection; falls back to empty for TTY prompt)
145
- * 2. Resolves target path (default to .cortex in cwd)
146
- * 3. When stdin is a TTY, prompts for unresolved name and/or path
147
- * 4. Uses `store.initialize` to create directory, index, and register
148
- * 5. Outputs the result
149
- *
150
- * Interactive mode activates automatically when `ctx.stdin.isTTY === true`.
151
- * In non-TTY environments (CI, pipes) the command behaves exactly as before —
152
- * no behavioral regression.
153
- *
154
- * @param ctx - The Cortex context (stdin TTY state used for interactive detection)
155
- * @param targetPath - Optional path for the store (defaults to .cortex in cwd)
156
- * @param options - Command options (name, format, categoryMode, description)
157
- * @param promptDeps - Injectable prompt functions; defaults to real `@inquirer/prompts` functions
158
- * @throws {InvalidArgumentError} When the store name is invalid
159
- * @throws {CommanderError} When the store already exists or init fails
160
- *
161
- * @example
162
- * ```typescript
163
- * // Explicit name + path (no prompts even in TTY):
164
- * await handleInit(ctx, './my-store', { name: 'my-project' });
165
- *
166
- * // Interactive (TTY detected, no --name given):
167
- * await handleInit(ctx, undefined, {}, defaultPromptDeps);
168
- * ```
169
- */
170
- export async function handleInit(
171
- ctx: CortexContext,
172
- targetPath: string | undefined,
173
- options: InitCommandOptions = {},
174
- promptDeps: PromptDeps = defaultPromptDeps,
175
- ): Promise<void> {
176
- const cwd = ctx.cwd ?? process.cwd();
177
- const stdout = ctx.stdout ?? process.stdout;
178
-
179
- const storeName = await resolveStoreNameOrEmpty(cwd, options.name, isTTY(ctx.stdin));
180
-
181
- const storePath = targetPath
182
- ? resolveUserPath(targetPath.trim(), cwd)
183
- : resolve(cwd, '.cortex');
184
-
185
- const resolved = await promptStoreInitOptions(
186
- ctx,
187
- { storeName, storePath },
188
- { name: options.name, path: targetPath },
189
- promptDeps,
190
- );
191
-
192
- const finalStoreName = resolved.storeName;
193
- const finalStorePath = resolved.storePath;
194
-
195
- if (!finalStoreName) {
196
- throwCliError({
197
- code: 'INVALID_STORE_NAME',
198
- message: 'Store name is required. Use --name to specify one.',
199
- });
200
- }
201
-
202
- const storeData: StoreData = {
203
- kind: 'filesystem',
204
- categoryMode: (options.categoryMode as CategoryMode) ?? 'free',
205
- categories: [],
206
- properties: {
207
- path: finalStorePath,
208
- },
209
- description: options.description,
210
- };
211
-
212
- const adapter = new FilesystemStorageAdapter(ctx.config as FilesystemConfigAdapter, {
213
- rootDirectory: finalStorePath,
214
- });
215
- const createResult = await initializeStore(adapter, finalStoreName, storeData);
216
- if (!createResult.ok()) {
217
- throwCliError(createResult.error);
218
- }
219
-
220
- const output: OutputStoreInit = { path: finalStorePath, name: finalStoreName };
221
- const format: OutputFormat = (options.format as OutputFormat) ?? 'yaml';
222
- writeOutput(output, format, stdout);
223
- }
224
-
225
- /**
226
- * The `init` subcommand for initializing a new memory store.
227
- *
228
- * Creates a new store at the specified path (or .cortex in current directory)
229
- * and registers it in the global registry. The store name is auto-detected
230
- * from the git repository name or can be explicitly provided.
231
- *
232
- * @example
233
- * ```bash
234
- * cortex store init # Auto-detect name from git
235
- * cortex store init --name my-project # Explicit name
236
- * cortex store init ./store --name custom # Custom path and name
237
- * ```
238
- */
239
- export const initCommand = new Command('init')
240
- .description('Initialize a new memory store')
241
- .argument('[path]', 'Path for the store (defaults to .cortex in current directory)')
242
- .option('-n, --name <name>', 'Explicit store name (otherwise auto-detected from git)')
243
- .option('-d, --description <description>', 'Optional description for the store')
244
- .option('-c, --category-mode <mode>', 'Category mode (free, strict, flat)', 'free')
245
- .option('-o, --format <format>', 'Output format (yaml, json, toon)', 'yaml')
246
- .action(async (path, options) => {
247
- const configCtx = await createCliConfigContext();
248
- if (!configCtx.ok()) {
249
- throwCliError(configCtx.error);
250
- }
251
-
252
- const { configAdapter, effectiveCwd } = configCtx.value;
253
-
254
- // Build a minimal context for handleInit. The init command cannot go
255
- // through the full CortexContext/adapterFactory because the store is
256
- // not yet registered — the adapter factory would throw STORE_NOT_FOUND.
257
- // handleInit only needs cwd, stdin, stdout, and config (to construct
258
- // its own adapter directly via initializeStore).
259
- const ctx = {
260
- config: configAdapter,
261
- cwd: effectiveCwd,
262
- stdin,
263
- stdout: processStdout,
264
- } as unknown as CortexContext;
265
-
266
- await handleInit(ctx, path, {
267
- name: options.name,
268
- description: options.description,
269
- categoryMode: options.categoryMode as CategoryMode,
270
- format: options.format,
271
- });
272
- });
@@ -1,175 +0,0 @@
1
- /**
2
- * Unit tests for the store list command handler.
3
- *
4
- * @module cli/store/commands/list.spec
5
- */
6
-
7
- import { describe, it, expect } from 'bun:test';
8
- import { handleList } from './list.ts';
9
- import {
10
- createMockContext,
11
- captureOutput,
12
- } from '../../test-helpers.spec.ts';
13
-
14
- describe('handleList', () => {
15
- it('should output empty store list when no stores are configured', async () => {
16
- const { ctx, stdout } = createMockContext({ stores: {} });
17
-
18
- await handleList(ctx, {}, { stdout });
19
-
20
- const output = captureOutput(stdout);
21
- // YAML output for empty stores list
22
- expect(output).toContain('stores');
23
- });
24
-
25
- it('should output a single store in YAML format by default', async () => {
26
- const { ctx, stdout } = createMockContext({
27
- stores: {
28
- global: {
29
- kind: 'filesystem',
30
- categoryMode: 'free',
31
- categories: {},
32
- properties: { path: '/mock/store' },
33
- },
34
- },
35
- });
36
-
37
- await handleList(ctx, {}, { stdout });
38
-
39
- const output = captureOutput(stdout);
40
- expect(output).toContain('global');
41
- expect(output).toContain('/mock/store');
42
- });
43
-
44
- it('should output multiple stores sorted alphabetically by name', async () => {
45
- const { ctx, stdout } = createMockContext({
46
- stores: {
47
- zebra: {
48
- kind: 'filesystem',
49
- categoryMode: 'free',
50
- categories: {},
51
- properties: { path: '/path/zebra' },
52
- },
53
- alpha: {
54
- kind: 'filesystem',
55
- categoryMode: 'free',
56
- categories: {},
57
- properties: { path: '/path/alpha' },
58
- },
59
- middle: {
60
- kind: 'filesystem',
61
- categoryMode: 'free',
62
- categories: {},
63
- properties: { path: '/path/middle' },
64
- },
65
- },
66
- });
67
-
68
- await handleList(ctx, {}, { stdout });
69
-
70
- const output = captureOutput(stdout);
71
- const alphaPos = output.indexOf('alpha');
72
- const middlePos = output.indexOf('middle');
73
- const zebraPos = output.indexOf('zebra');
74
-
75
- expect(alphaPos).toBeLessThan(middlePos);
76
- expect(middlePos).toBeLessThan(zebraPos);
77
- });
78
-
79
- it('should output stores in JSON format when format is json', async () => {
80
- const { ctx, stdout } = createMockContext({
81
- stores: {
82
- global: {
83
- kind: 'filesystem',
84
- categoryMode: 'free',
85
- categories: {},
86
- properties: { path: '/mock/store' },
87
- },
88
- },
89
- });
90
-
91
- await handleList(ctx, { format: 'json' }, { stdout });
92
-
93
- const output = captureOutput(stdout);
94
- const parsed = JSON.parse(output);
95
- const stores = parsed.stores ?? parsed.value?.stores;
96
- expect(Array.isArray(stores)).toBe(true);
97
- expect(stores).toEqual([
98
- {
99
- name: 'global',
100
- path: '/mock/store',
101
- },
102
- ]);
103
- });
104
-
105
- it('should include store paths in the output', async () => {
106
- const { ctx, stdout } = createMockContext({
107
- stores: {
108
- work: {
109
- kind: 'filesystem',
110
- categoryMode: 'free',
111
- categories: {},
112
- properties: { path: '/home/user/work-memories' },
113
- },
114
- },
115
- });
116
-
117
- await handleList(ctx, {}, { stdout });
118
-
119
- const output = captureOutput(stdout);
120
- expect(output).toContain('work');
121
- expect(output).toContain('/home/user/work-memories');
122
- });
123
-
124
- it('should write output to the injected stdout stream', async () => {
125
- const { ctx } = createMockContext();
126
- const { PassThrough } = await import('node:stream');
127
- const customStdout = new PassThrough();
128
- let captured = '';
129
- customStdout.on('data', (chunk: Buffer | string) => {
130
- captured += chunk.toString();
131
- });
132
-
133
- await handleList(ctx, {}, { stdout: customStdout });
134
-
135
- expect(captured.length).toBeGreaterThan(0);
136
- });
137
-
138
- it('should use ctx.stdout when no deps.stdout is provided', async () => {
139
- const { ctx, stdout } = createMockContext();
140
-
141
- // Call without deps – handler falls back to ctx.stdout then process.stdout.
142
- // We cannot easily capture process.stdout, so pass ctx.stdout via deps.
143
- await handleList(ctx, {}, { stdout });
144
-
145
- const output = captureOutput(stdout);
146
- expect(output.length).toBeGreaterThan(0);
147
- });
148
-
149
- it('should output two stores with correct names and paths in YAML', async () => {
150
- const { ctx, stdout } = createMockContext({
151
- stores: {
152
- personal: {
153
- kind: 'filesystem',
154
- categoryMode: 'free',
155
- categories: {},
156
- properties: { path: '/home/user/personal' },
157
- },
158
- work: {
159
- kind: 'filesystem',
160
- categoryMode: 'free',
161
- categories: {},
162
- properties: { path: '/home/user/work' },
163
- },
164
- },
165
- });
166
-
167
- await handleList(ctx, { format: 'yaml' }, { stdout });
168
-
169
- const output = captureOutput(stdout);
170
- expect(output).toContain('personal');
171
- expect(output).toContain('/home/user/personal');
172
- expect(output).toContain('work');
173
- expect(output).toContain('/home/user/work');
174
- });
175
- });