@outputai/cli 0.2.1-next.f1502fb.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/run.js +2 -2
  2. package/dist/api/generated/api.d.ts +21 -5
  3. package/dist/api/generated/api.js +1 -1
  4. package/dist/api/http_client.js +24 -19
  5. package/dist/assets/docker/docker-compose-dev.yml +5 -9
  6. package/dist/commands/dev/index.js +12 -1
  7. package/dist/commands/init.d.ts +1 -0
  8. package/dist/commands/init.js +5 -1
  9. package/dist/commands/init.spec.js +10 -5
  10. package/dist/commands/workflow/run.d.ts +1 -1
  11. package/dist/commands/workflow/run.js +8 -5
  12. package/dist/commands/workflow/run.spec.js +3 -3
  13. package/dist/commands/workflow/runs/list.d.ts +1 -0
  14. package/dist/commands/workflow/runs/list.js +7 -0
  15. package/dist/commands/workflow/start.d.ts +1 -1
  16. package/dist/commands/workflow/start.js +8 -5
  17. package/dist/commands/workflow/start.spec.js +1 -1
  18. package/dist/config.d.ts +11 -38
  19. package/dist/config.js +34 -42
  20. package/dist/config.spec.d.ts +1 -0
  21. package/dist/config.spec.js +129 -0
  22. package/dist/generated/framework_version.json +1 -1
  23. package/dist/hooks/init.d.ts +4 -0
  24. package/dist/hooks/init.js +14 -2
  25. package/dist/hooks/init.spec.js +79 -5
  26. package/dist/services/docker.js +5 -2
  27. package/dist/services/docker.spec.js +74 -3
  28. package/dist/services/messages.js +2 -1
  29. package/dist/services/project_scaffold.d.ts +1 -1
  30. package/dist/services/project_scaffold.js +16 -1
  31. package/dist/services/workflow_runs.d.ts +1 -0
  32. package/dist/services/workflow_runs.js +3 -0
  33. package/dist/templates/project/.env.example.template +17 -0
  34. package/dist/utils/credentials_loader.d.ts +1 -0
  35. package/dist/utils/credentials_loader.js +18 -0
  36. package/dist/utils/credentials_loader.spec.d.ts +1 -0
  37. package/dist/utils/credentials_loader.spec.js +84 -0
  38. package/dist/utils/env_loader.js +1 -2
  39. package/dist/utils/error_handler.js +10 -8
  40. package/dist/utils/validation.d.ts +13 -0
  41. package/dist/utils/validation.js +31 -0
  42. package/dist/utils/validation.spec.js +47 -1
  43. package/dist/views/dev.js +3 -3
  44. package/dist/views/workflow/list.js +10 -8
  45. package/package.json +10 -10
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { config } from '#config.js';
3
+ import { InvalidPortError } from '#utils/validation.js';
4
+ describe('config', () => {
5
+ const envVars = [
6
+ 'OUTPUT_API_URL',
7
+ 'OUTPUT_API_HOST_PORT',
8
+ 'OUTPUT_TEMPORAL_UI_HOST_PORT',
9
+ 'OUTPUT_API_AUTH_TOKEN',
10
+ 'DOCKER_SERVICE_NAME',
11
+ 'OUTPUT_DEBUG',
12
+ 'OUTPUT_CLI_ENV',
13
+ 'OUTPUT_TRACE_REMOTE_S3_BUCKET',
14
+ 'OUTPUT_AWS_REGION',
15
+ 'OUTPUT_AWS_ACCESS_KEY_ID',
16
+ 'OUTPUT_AWS_SECRET_ACCESS_KEY'
17
+ ];
18
+ const saved = {};
19
+ beforeEach(() => {
20
+ for (const key of envVars) {
21
+ saved[key] = process.env[key];
22
+ }
23
+ });
24
+ afterEach(() => {
25
+ for (const key of envVars) {
26
+ if (saved[key] === undefined) {
27
+ delete process.env[key];
28
+ }
29
+ else {
30
+ process.env[key] = saved[key];
31
+ }
32
+ }
33
+ });
34
+ it('reads env vars lazily, not at module evaluation time', () => {
35
+ process.env.OUTPUT_API_URL = 'https://lazy-test.example.com';
36
+ expect(config.apiUrl).toBe('https://lazy-test.example.com');
37
+ process.env.OUTPUT_API_URL = 'https://changed.example.com';
38
+ expect(config.apiUrl).toBe('https://changed.example.com');
39
+ });
40
+ it('falls back to defaults when env vars are unset', () => {
41
+ delete process.env.OUTPUT_API_URL;
42
+ delete process.env.OUTPUT_API_HOST_PORT;
43
+ delete process.env.OUTPUT_TEMPORAL_UI_HOST_PORT;
44
+ delete process.env.DOCKER_SERVICE_NAME;
45
+ delete process.env.OUTPUT_DEBUG;
46
+ delete process.env.OUTPUT_CLI_ENV;
47
+ expect(config.apiUrl).toBe('http://localhost:3001');
48
+ expect(config.ports).toEqual({ temporalUi: 8080, api: 3001 });
49
+ expect(config.temporalUiUrl).toBe('http://localhost:8080');
50
+ expect(config.dockerServiceName).toBe('output-sdk');
51
+ expect(config.debugMode).toBe(false);
52
+ expect(config.envFile).toBe('.env');
53
+ });
54
+ it('derives apiUrl from OUTPUT_API_HOST_PORT when OUTPUT_API_URL is unset', () => {
55
+ delete process.env.OUTPUT_API_URL;
56
+ process.env.OUTPUT_API_HOST_PORT = '3002';
57
+ expect(config.apiUrl).toBe('http://localhost:3002');
58
+ });
59
+ it('OUTPUT_API_URL takes precedence over OUTPUT_API_HOST_PORT', () => {
60
+ process.env.OUTPUT_API_URL = 'https://api.example.com';
61
+ process.env.OUTPUT_API_HOST_PORT = '3002';
62
+ expect(config.apiUrl).toBe('https://api.example.com');
63
+ });
64
+ it('empty-string OUTPUT_API_URL falls through to OUTPUT_API_HOST_PORT (|| not ??)', () => {
65
+ process.env.OUTPUT_API_URL = '';
66
+ process.env.OUTPUT_API_HOST_PORT = '3002';
67
+ expect(config.apiUrl).toBe('http://localhost:3002');
68
+ });
69
+ it('reads port overrides from env vars', () => {
70
+ process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '8081';
71
+ process.env.OUTPUT_API_HOST_PORT = '3002';
72
+ expect(config.ports).toEqual({ temporalUi: 8081, api: 3002 });
73
+ expect(config.temporalUiUrl).toBe('http://localhost:8081');
74
+ });
75
+ it('treats empty-string port env vars as unset (matches Compose semantics)', () => {
76
+ delete process.env.OUTPUT_API_URL;
77
+ process.env.OUTPUT_API_HOST_PORT = '';
78
+ process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '';
79
+ expect(config.apiUrl).toBe('http://localhost:3001');
80
+ expect(config.ports).toEqual({ temporalUi: 8080, api: 3001 });
81
+ expect(config.temporalUiUrl).toBe('http://localhost:8080');
82
+ });
83
+ it('throws InvalidPortError on non-numeric port values', () => {
84
+ delete process.env.OUTPUT_API_URL;
85
+ process.env.OUTPUT_API_HOST_PORT = 'abc';
86
+ expect(() => config.ports).toThrow(InvalidPortError);
87
+ expect(() => config.apiUrl).toThrow(InvalidPortError);
88
+ });
89
+ it('throws InvalidPortError on out-of-range port values', () => {
90
+ process.env.OUTPUT_API_HOST_PORT = '99999';
91
+ expect(() => config.ports).toThrow(InvalidPortError);
92
+ });
93
+ it('throws InvalidPortError on trailing-junk port values', () => {
94
+ process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '8080abc';
95
+ expect(() => config.ports).toThrow(InvalidPortError);
96
+ });
97
+ it('throws InvalidPortError on port 0 (Compose treats 0 as ephemeral - prevents CLI/Docker desync)', () => {
98
+ process.env.OUTPUT_API_HOST_PORT = '0';
99
+ expect(() => config.ports).toThrow(InvalidPortError);
100
+ });
101
+ it('reads apiToken from env', () => {
102
+ process.env.OUTPUT_API_AUTH_TOKEN = 'test-token-123';
103
+ expect(config.apiToken).toBe('test-token-123');
104
+ delete process.env.OUTPUT_API_AUTH_TOKEN;
105
+ expect(config.apiToken).toBeUndefined();
106
+ });
107
+ it('reads debugMode as boolean', () => {
108
+ process.env.OUTPUT_DEBUG = 'true';
109
+ expect(config.debugMode).toBe(true);
110
+ process.env.OUTPUT_DEBUG = 'false';
111
+ expect(config.debugMode).toBe(false);
112
+ });
113
+ it('reads s3 config lazily', () => {
114
+ process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET = 'my-bucket';
115
+ process.env.OUTPUT_AWS_REGION = 'us-west-2';
116
+ process.env.OUTPUT_AWS_ACCESS_KEY_ID = 'AKIA123';
117
+ process.env.OUTPUT_AWS_SECRET_ACCESS_KEY = 'secret123';
118
+ expect(config.s3).toEqual({
119
+ bucket: 'my-bucket',
120
+ region: 'us-west-2',
121
+ accessKeyId: 'AKIA123',
122
+ secretAccessKey: 'secret123'
123
+ });
124
+ });
125
+ it('has static properties that are not env-derived', () => {
126
+ expect(config.requestTimeout).toBe(30000);
127
+ expect(config.agentConfigDir).toBe('.outputai');
128
+ });
129
+ });
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.2.1-next.f1502fb.0"
2
+ "framework": "0.3.0"
3
3
  }
@@ -1,3 +1,7 @@
1
1
  import { Hook } from '@oclif/core';
2
+ export declare const INTERACTIVE_FLAGS: string[];
3
+ export declare const GLOBAL_FLAGS: Set<string>;
4
+ export declare const hasInteractiveFlag: (argv: string[]) => boolean;
5
+ export declare const stripGlobalFlags: (argv: string[]) => void;
2
6
  declare const hook: Hook<'init'>;
3
7
  export default hook;
@@ -1,8 +1,20 @@
1
1
  import { ux } from '@oclif/core';
2
2
  import { checkForUpdate } from '#services/version_check.js';
3
3
  import { setNonInteractive } from '#utils/interactive.js';
4
- const hook = async function () {
5
- if (process.argv.includes('--yes') || process.argv.includes('--non-interactive')) {
4
+ export const INTERACTIVE_FLAGS = ['--yes', '--non-interactive'];
5
+ export const GLOBAL_FLAGS = new Set(INTERACTIVE_FLAGS);
6
+ export const hasInteractiveFlag = (argv) => argv.some(arg => INTERACTIVE_FLAGS.includes(arg));
7
+ export const stripGlobalFlags = (argv) => {
8
+ const kept = argv.filter(arg => !GLOBAL_FLAGS.has(arg));
9
+ if (kept.length !== argv.length) {
10
+ argv.splice(0, argv.length, ...kept);
11
+ }
12
+ };
13
+ const hook = async function (opts) {
14
+ const interactive = hasInteractiveFlag(opts.argv) || hasInteractiveFlag(process.argv);
15
+ stripGlobalFlags(opts.argv);
16
+ stripGlobalFlags(process.argv);
17
+ if (interactive) {
6
18
  setNonInteractive(true);
7
19
  }
8
20
  try {
@@ -1,9 +1,13 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
3
  import { checkForUpdate } from '#services/version_check.js';
4
+ import { setNonInteractive } from '#utils/interactive.js';
4
5
  vi.mock('#services/version_check.js', () => ({
5
6
  checkForUpdate: vi.fn()
6
7
  }));
8
+ vi.mock('#utils/interactive.js', () => ({
9
+ setNonInteractive: vi.fn()
10
+ }));
7
11
  vi.mock('@oclif/core', () => ({
8
12
  ux: {
9
13
  stdout: vi.fn(),
@@ -11,7 +15,7 @@ vi.mock('@oclif/core', () => ({
11
15
  }
12
16
  }));
13
17
  import { ux } from '@oclif/core';
14
- import hook from './init.js';
18
+ import hook, { hasInteractiveFlag, stripGlobalFlags } from './init.js';
15
19
  describe('init hook', () => {
16
20
  beforeEach(() => {
17
21
  vi.clearAllMocks();
@@ -26,7 +30,7 @@ describe('init hook', () => {
26
30
  latestVersion: '1.0.0'
27
31
  });
28
32
  const ctx = createHookContext();
29
- await hook.call(ctx, {});
33
+ await hook.call(ctx, { argv: [], id: undefined });
30
34
  expect(checkForUpdate).toHaveBeenCalledWith('0.8.4', '/tmp/test-cache');
31
35
  expect(ux.stdout).toHaveBeenCalled();
32
36
  const output = vi.mocked(ux.stdout).mock.calls.map(c => c[0]).join('\n');
@@ -42,13 +46,83 @@ describe('init hook', () => {
42
46
  latestVersion: '0.8.4'
43
47
  });
44
48
  const ctx = createHookContext();
45
- await hook.call(ctx, {});
49
+ await hook.call(ctx, { argv: [], id: undefined });
46
50
  expect(ux.stdout).not.toHaveBeenCalled();
47
51
  });
48
52
  it('should silently handle errors', async () => {
49
53
  vi.mocked(checkForUpdate).mockRejectedValue(new Error('network failure'));
50
54
  const ctx = createHookContext();
51
- await hook.call(ctx, {});
55
+ await hook.call(ctx, { argv: [], id: undefined });
52
56
  expect(ux.stdout).not.toHaveBeenCalled();
53
57
  });
58
+ describe('global interactive flags', () => {
59
+ const originalArgv = process.argv;
60
+ beforeEach(() => {
61
+ vi.mocked(checkForUpdate).mockResolvedValue({
62
+ updateAvailable: false,
63
+ currentVersion: '0.8.4',
64
+ latestVersion: '0.8.4'
65
+ });
66
+ });
67
+ afterEach(() => {
68
+ process.argv = originalArgv;
69
+ });
70
+ it('should mutate opts.argv in place to strip --yes', async () => {
71
+ process.argv = ['node', 'run.js', 'init', '--yes', 'my-project'];
72
+ const optsArgv = ['--yes', 'my-project'];
73
+ const argvRef = optsArgv;
74
+ const ctx = createHookContext();
75
+ await hook.call(ctx, { argv: optsArgv, id: 'init' });
76
+ expect(setNonInteractive).toHaveBeenCalledWith(true);
77
+ expect(optsArgv).toBe(argvRef);
78
+ expect(optsArgv).toEqual(['my-project']);
79
+ expect(process.argv).toEqual(['node', 'run.js', 'init', 'my-project']);
80
+ });
81
+ it('should mutate opts.argv in place to strip --non-interactive', async () => {
82
+ process.argv = ['node', 'run.js', 'init', '--non-interactive'];
83
+ const optsArgv = ['--non-interactive'];
84
+ const ctx = createHookContext();
85
+ await hook.call(ctx, { argv: optsArgv, id: 'init' });
86
+ expect(setNonInteractive).toHaveBeenCalledWith(true);
87
+ expect(optsArgv).toEqual([]);
88
+ expect(process.argv).toEqual(['node', 'run.js', 'init']);
89
+ });
90
+ it('should leave argv untouched when no global flag is present', async () => {
91
+ process.argv = ['node', 'run.js', 'init', '--skip-env'];
92
+ const optsArgv = ['--skip-env'];
93
+ const ctx = createHookContext();
94
+ await hook.call(ctx, { argv: optsArgv, id: 'init' });
95
+ expect(setNonInteractive).not.toHaveBeenCalled();
96
+ expect(optsArgv).toEqual(['--skip-env']);
97
+ expect(process.argv).toEqual(['node', 'run.js', 'init', '--skip-env']);
98
+ });
99
+ });
100
+ describe('hasInteractiveFlag', () => {
101
+ it('returns true when --yes is present', () => {
102
+ expect(hasInteractiveFlag(['init', '--yes', 'foo'])).toBe(true);
103
+ });
104
+ it('returns true when --non-interactive is present', () => {
105
+ expect(hasInteractiveFlag(['--non-interactive'])).toBe(true);
106
+ });
107
+ it('returns false for unrelated flags', () => {
108
+ expect(hasInteractiveFlag(['init', '--skip-env', '--skip-git'])).toBe(false);
109
+ });
110
+ it('returns false for an empty argv', () => {
111
+ expect(hasInteractiveFlag([])).toBe(false);
112
+ });
113
+ });
114
+ describe('stripGlobalFlags', () => {
115
+ it('mutates argv in place to remove global flags', () => {
116
+ const argv = ['init', '--yes', 'foo', '--non-interactive'];
117
+ const ref = argv;
118
+ stripGlobalFlags(argv);
119
+ expect(argv).toBe(ref);
120
+ expect(argv).toEqual(['init', 'foo']);
121
+ });
122
+ it('leaves argv untouched when no global flag is present', () => {
123
+ const argv = ['init', '--skip-env'];
124
+ stripGlobalFlags(argv);
125
+ expect(argv).toEqual(['init', '--skip-env']);
126
+ });
127
+ });
54
128
  });
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { ux } from '@oclif/core';
5
5
  import semver from 'semver';
6
+ import { config } from '#config.js';
6
7
  const DEFAULT_COMPOSE_PATH = '../assets/docker/docker-compose-dev.yml';
7
8
  export const SERVICE_HEALTH = {
8
9
  HEALTHY: 'healthy',
@@ -100,7 +101,7 @@ export function parseServiceStatus(jsonOutput) {
100
101
  });
101
102
  }
102
103
  export async function getServiceStatus(dockerComposePath) {
103
- const result = execFileSync('docker', ['compose', '-f', dockerComposePath, 'ps', '--all', '--format', 'json'], { encoding: 'utf-8', cwd: process.cwd() });
104
+ const result = execFileSync('docker', ['compose', '-f', dockerComposePath, '--project-name', config.dockerServiceName, 'ps', '--all', '--format', 'json'], { encoding: 'utf-8', cwd: process.cwd() });
104
105
  return parseServiceStatus(result);
105
106
  }
106
107
  export function isServiceHealthy(service) {
@@ -126,6 +127,7 @@ export async function startDockerCompose(dockerComposePath, pullPolicy) {
126
127
  'compose',
127
128
  '-f', dockerComposePath,
128
129
  '--project-directory', process.cwd(),
130
+ '--project-name', config.dockerServiceName,
129
131
  'up'
130
132
  ];
131
133
  if (pullPolicy) {
@@ -143,6 +145,7 @@ export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
143
145
  'compose',
144
146
  '-f', dockerComposePath,
145
147
  '--project-directory', process.cwd(),
148
+ '--project-name', config.dockerServiceName,
146
149
  'up', '-d'
147
150
  ];
148
151
  if (pullPolicy) {
@@ -152,6 +155,6 @@ export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
152
155
  }
153
156
  export async function stopDockerCompose(dockerComposePath) {
154
157
  ux.stdout('⏹️ Stopping services...\n');
155
- execFileSync('docker', ['compose', '-f', dockerComposePath, 'down'], { stdio: 'inherit', cwd: process.cwd() });
158
+ execFileSync('docker', ['compose', '-f', dockerComposePath, '--project-directory', process.cwd(), '--project-name', config.dockerServiceName, 'down'], { stdio: 'inherit', cwd: process.cwd() });
156
159
  }
157
160
  export { isDockerInstalled, DockerValidationError };
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { execFileSync } from 'node:child_process';
3
- import { parseServiceStatus, getServiceStatus, waitForServicesHealthy, isServiceHealthy, isServiceFailed } from './docker.js';
2
+ import { execFileSync, spawn } from 'node:child_process';
3
+ import { parseServiceStatus, getServiceStatus, startDockerCompose, startDockerComposeDetached, stopDockerCompose, waitForServicesHealthy, isServiceHealthy, isServiceFailed } from './docker.js';
4
4
  vi.mock('node:child_process', () => ({
5
5
  execSync: vi.fn(),
6
6
  execFileSync: vi.fn(),
@@ -73,7 +73,7 @@ describe('docker service', () => {
73
73
  const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}';
74
74
  vi.mocked(execFileSync).mockReturnValue(mockOutput);
75
75
  await getServiceStatus('/path/to/docker-compose.yml');
76
- expect(execFileSync).toHaveBeenCalledWith('docker', ['compose', '-f', '/path/to/docker-compose.yml', 'ps', '--all', '--format', 'json'], expect.objectContaining({ encoding: 'utf-8' }));
76
+ expect(execFileSync).toHaveBeenCalledWith('docker', ['compose', '-f', '/path/to/docker-compose.yml', '--project-name', 'output-sdk', 'ps', '--all', '--format', 'json'], expect.objectContaining({ encoding: 'utf-8' }));
77
77
  });
78
78
  it('should return parsed service status', async () => {
79
79
  const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}';
@@ -89,6 +89,77 @@ describe('docker service', () => {
89
89
  await expect(getServiceStatus('/path/to/docker-compose.yml')).rejects.toThrow();
90
90
  });
91
91
  });
92
+ describe('startDockerCompose', () => {
93
+ it('should pass --project-name to docker compose up', async () => {
94
+ await startDockerCompose('/path/to/docker-compose.yml');
95
+ expect(spawn).toHaveBeenCalledWith('docker', [
96
+ 'compose', '-f', '/path/to/docker-compose.yml',
97
+ '--project-directory', process.cwd(),
98
+ '--project-name', 'output-sdk',
99
+ 'up'
100
+ ], expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], cwd: process.cwd() }));
101
+ });
102
+ it('should append --pull when pullPolicy is provided', async () => {
103
+ await startDockerCompose('/path/to/docker-compose.yml', 'always');
104
+ expect(spawn).toHaveBeenCalledWith('docker', [
105
+ 'compose', '-f', '/path/to/docker-compose.yml',
106
+ '--project-directory', process.cwd(),
107
+ '--project-name', 'output-sdk',
108
+ 'up', '--pull', 'always'
109
+ ], expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], cwd: process.cwd() }));
110
+ });
111
+ });
112
+ describe('startDockerComposeDetached', () => {
113
+ it('should pass --project-name and -d to docker compose up', () => {
114
+ vi.mocked(execFileSync).mockReturnValue('');
115
+ startDockerComposeDetached('/path/to/docker-compose.yml');
116
+ expect(execFileSync).toHaveBeenCalledWith('docker', [
117
+ 'compose', '-f', '/path/to/docker-compose.yml',
118
+ '--project-directory', process.cwd(),
119
+ '--project-name', 'output-sdk',
120
+ 'up', '-d'
121
+ ], expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }));
122
+ });
123
+ it('should append --pull when pullPolicy is provided', () => {
124
+ vi.mocked(execFileSync).mockReturnValue('');
125
+ startDockerComposeDetached('/path/to/docker-compose.yml', 'missing');
126
+ expect(execFileSync).toHaveBeenCalledWith('docker', [
127
+ 'compose', '-f', '/path/to/docker-compose.yml',
128
+ '--project-directory', process.cwd(),
129
+ '--project-name', 'output-sdk',
130
+ 'up', '-d', '--pull', 'missing'
131
+ ], expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }));
132
+ });
133
+ });
134
+ describe('DOCKER_SERVICE_NAME wiring', () => {
135
+ const saved = process.env.DOCKER_SERVICE_NAME;
136
+ afterEach(() => {
137
+ if (saved === undefined) {
138
+ delete process.env.DOCKER_SERVICE_NAME;
139
+ }
140
+ else {
141
+ process.env.DOCKER_SERVICE_NAME = saved;
142
+ }
143
+ });
144
+ it('threads DOCKER_SERVICE_NAME through to --project-name (not hardcoded output-sdk)', async () => {
145
+ process.env.DOCKER_SERVICE_NAME = 'custom-project';
146
+ vi.mocked(execFileSync).mockReturnValue('');
147
+ await stopDockerCompose('/path/to/docker-compose.yml');
148
+ expect(execFileSync).toHaveBeenCalledWith('docker', [
149
+ 'compose', '-f', '/path/to/docker-compose.yml',
150
+ '--project-directory', process.cwd(),
151
+ '--project-name', 'custom-project',
152
+ 'down'
153
+ ], expect.objectContaining({ stdio: 'inherit' }));
154
+ });
155
+ });
156
+ describe('stopDockerCompose', () => {
157
+ it('should pass --project-name and --project-directory to docker compose down', async () => {
158
+ vi.mocked(execFileSync).mockReturnValue('');
159
+ await stopDockerCompose('/path/to/docker-compose.yml');
160
+ expect(execFileSync).toHaveBeenCalledWith('docker', ['compose', '-f', '/path/to/docker-compose.yml', '--project-directory', process.cwd(), '--project-name', 'output-sdk', 'down'], expect.objectContaining({ stdio: 'inherit' }));
161
+ });
162
+ });
92
163
  describe('isServiceHealthy', () => {
93
164
  it('should return true for a running service with health: healthy', () => {
94
165
  expect(isServiceHealthy({ name: 'redis', state: 'running', health: 'healthy', ports: [] })).toBe(true);
@@ -2,6 +2,7 @@
2
2
  * Success and informational messages for project initialization
3
3
  */
4
4
  import { ux } from '@oclif/core';
5
+ import { config } from '#config.js';
5
6
  /**
6
7
  * Creates a colored ASCII art banner for Output.ai
7
8
  */
@@ -180,7 +181,7 @@ export const getProjectSuccessMessage = (folderName, installSuccess, credentials
180
181
  note: 'Execute in a new terminal after services are running'
181
182
  }, {
182
183
  step: 'Monitor workflows',
183
- command: 'open http://localhost:8080',
184
+ command: `open ${config.temporalUiUrl}`,
184
185
  note: 'Access Temporal UI for workflow visualization'
185
186
  });
186
187
  // Format each step with proper indentation and colors
@@ -27,5 +27,5 @@ export declare function createSigintHandler(projectPath: string, folderCreated:
27
27
  * @param skipEnv - Whether to skip environment configuration prompts
28
28
  * @param folderName - Optional folder name to skip folder name prompt
29
29
  */
30
- export declare function runInit(skipEnv?: boolean, folderName?: string): Promise<void>;
30
+ export declare function runInit(skipEnv?: boolean, skipGit?: boolean, folderName?: string): Promise<void>;
31
31
  export {};
@@ -119,6 +119,18 @@ async function executeNpmInstall(projectPath) {
119
119
  async function initializeAgents(projectPath) {
120
120
  await initializeAgentConfig({ projectRoot: projectPath, force: false });
121
121
  }
122
+ async function maybeInitializeGit(projectPath) {
123
+ const shouldInit = await confirm({
124
+ message: 'Initialize a git repository?',
125
+ default: true
126
+ });
127
+ if (!shouldInit) {
128
+ return false;
129
+ }
130
+ return executeCommandWithMessages(async () => {
131
+ await executeCommand('git', ['init'], projectPath);
132
+ }, 'Initializing git repository...', 'Git repository initialized');
133
+ }
122
134
  /**
123
135
  * Format error message for init errors
124
136
  * Single responsibility: only format error messages, no cleanup logic
@@ -172,7 +184,7 @@ function handleRunInitError(error, projectPath, projectFolderCreated) {
172
184
  * @param skipEnv - Whether to skip environment configuration prompts
173
185
  * @param folderName - Optional folder name to skip folder name prompt
174
186
  */
175
- export async function runInit(skipEnv = false, folderName) {
187
+ export async function runInit(skipEnv = false, skipGit = false, folderName) {
176
188
  // Track state for SIGINT cleanup using an object to avoid let
177
189
  const state = {
178
190
  projectFolderCreated: false,
@@ -210,6 +222,9 @@ export async function runInit(skipEnv = false, folderName) {
210
222
  await fs.copyFile(path.join(config.projectPath, '.env.example'), path.join(config.projectPath, '.env'));
211
223
  await executeCommandWithMessages(() => initializeAgents(config.projectPath), 'Initializing agent system...', 'Agent system initialized');
212
224
  const installSuccess = await executeCommandWithMessages(() => executeNpmInstall(config.projectPath), 'Installing dependencies...', 'Dependencies installed');
225
+ if (!skipGit) {
226
+ await maybeInitializeGit(config.projectPath);
227
+ }
213
228
  const nextSteps = getProjectSuccessMessage(config.folderName, installSuccess, credentialsConfigured);
214
229
  ux.stdout('Project created successfully!');
215
230
  ux.stdout(nextSteps);
@@ -9,6 +9,7 @@ export interface WorkflowRunsResult {
9
9
  }
10
10
  export interface FetchWorkflowRunsOptions {
11
11
  workflowType?: string;
12
+ catalog?: string;
12
13
  limit?: number;
13
14
  }
14
15
  export declare function fetchWorkflowRuns(options?: FetchWorkflowRunsOptions): Promise<WorkflowRunsResult>;
@@ -10,6 +10,9 @@ export async function fetchWorkflowRuns(options = {}) {
10
10
  if (options.workflowType) {
11
11
  params.workflowType = options.workflowType;
12
12
  }
13
+ if (options.catalog) {
14
+ params.catalog = options.catalog;
15
+ }
13
16
  const response = await getWorkflowRuns(params);
14
17
  if (!response) {
15
18
  throw new Error('Failed to connect to API server. Is it running?');
@@ -7,3 +7,20 @@ ANTHROPIC_API_KEY=credential:anthropic.api_key
7
7
  # Configure if you plan to use OpenAI in your LLM prompts
8
8
  OPENAI_API_KEY=credential:openai.api_key
9
9
 
10
+ # --- Host port overrides (for running multiple dev stacks) ---
11
+ # Only the host-side port changes; inter-service traffic (e.g. worker -> Temporal
12
+ # at temporal:7233) is unaffected.
13
+ # OUTPUT_API_HOST_PORT=3001
14
+ # OUTPUT_TEMPORAL_UI_HOST_PORT=8080
15
+
16
+ # --- API connection ---
17
+ # If OUTPUT_API_URL is set, it overrides OUTPUT_API_HOST_PORT.
18
+ # Use OUTPUT_API_URL for remote/staging servers; use OUTPUT_API_HOST_PORT for local port shifts.
19
+ # OUTPUT_API_URL=http://localhost:3001
20
+
21
+ # --- Project isolation ---
22
+ # Compose prefixes named volumes with the project name, so changing
23
+ # DOCKER_SERVICE_NAME creates fresh volumes and leaves the previous ones behind.
24
+ # Run `docker volume ls --filter name=<old-project-name>` to find orphaned
25
+ # volumes and `docker volume rm` to remove them.
26
+
@@ -0,0 +1 @@
1
+ export declare const loadCredentialRefs: () => void;
@@ -0,0 +1,18 @@
1
+ import { InvalidCredentialsKeyError, MalformedCredentialsKeyError, MissingKeyError, resolveCredentialRefs } from '@outputai/credentials';
2
+ const isCredentialsConfigError = (error) => error instanceof MissingKeyError ||
3
+ error instanceof InvalidCredentialsKeyError ||
4
+ error instanceof MalformedCredentialsKeyError;
5
+ export const loadCredentialRefs = () => {
6
+ try {
7
+ resolveCredentialRefs();
8
+ }
9
+ catch (error) {
10
+ if (isCredentialsConfigError(error)) {
11
+ console.error(`Error: ${error.message}`);
12
+ process.exit(1);
13
+ }
14
+ else {
15
+ throw error;
16
+ }
17
+ }
18
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as credentials from '@outputai/credentials';
3
+ import { loadCredentialRefs } from './credentials_loader.js';
4
+ vi.mock('@outputai/credentials', async () => {
5
+ const actual = await vi.importActual('@outputai/credentials');
6
+ return {
7
+ ...actual,
8
+ resolveCredentialRefs: vi.fn()
9
+ };
10
+ });
11
+ describe('loadCredentialRefs', () => {
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ });
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+ it('should call resolveCredentialRefs without errors when no credentials are misconfigured', () => {
19
+ vi.mocked(credentials.resolveCredentialRefs).mockReturnValue([]);
20
+ expect(() => loadCredentialRefs()).not.toThrow();
21
+ expect(credentials.resolveCredentialRefs).toHaveBeenCalledTimes(1);
22
+ });
23
+ it('should print a clean error message and exit on MissingKeyError without dumping a stack trace', () => {
24
+ vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
25
+ throw new credentials.MissingKeyError();
26
+ });
27
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
28
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
29
+ loadCredentialRefs();
30
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
31
+ const printedMessage = consoleErrorSpy.mock.calls[0]?.[0];
32
+ expect(printedMessage).toContain('No credentials key found');
33
+ expect(printedMessage).toContain('OUTPUT_CREDENTIALS_KEY');
34
+ expect(printedMessage).toContain('config/credentials.key');
35
+ expect(printedMessage).not.toContain(' at ');
36
+ expect(exitSpy).toHaveBeenCalledWith(1);
37
+ });
38
+ it('should include the environment-specific hints in the printed message when an environment is set', () => {
39
+ vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
40
+ throw new credentials.MissingKeyError('production');
41
+ });
42
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
43
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
44
+ loadCredentialRefs();
45
+ const printedMessage = consoleErrorSpy.mock.calls[0]?.[0];
46
+ expect(printedMessage).toContain('OUTPUT_CREDENTIALS_KEY_PRODUCTION');
47
+ expect(printedMessage).toContain('config/credentials/production.key');
48
+ expect(exitSpy).toHaveBeenCalledWith(1);
49
+ });
50
+ it('should print a clean error message and exit on InvalidCredentialsKeyError', () => {
51
+ vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
52
+ throw new credentials.InvalidCredentialsKeyError('config/credentials.yml.enc');
53
+ });
54
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
55
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
56
+ loadCredentialRefs();
57
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
58
+ const printedMessage = consoleErrorSpy.mock.calls[0]?.[0];
59
+ expect(printedMessage).toContain('Failed to decrypt config/credentials.yml.enc');
60
+ expect(printedMessage).toContain('does not match');
61
+ expect(printedMessage).not.toContain(' at ');
62
+ expect(exitSpy).toHaveBeenCalledWith(1);
63
+ });
64
+ it('should print a clean error message and exit on MalformedCredentialsKeyError', () => {
65
+ vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
66
+ throw new credentials.MalformedCredentialsKeyError('config/credentials.yml.enc', 'hex string expected, got unpadded hex of length 55');
67
+ });
68
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
69
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
70
+ loadCredentialRefs();
71
+ const printedMessage = consoleErrorSpy.mock.calls[0]?.[0];
72
+ expect(printedMessage).toContain('is malformed');
73
+ expect(printedMessage).toContain('must be exactly 64 hex characters');
74
+ expect(printedMessage).toContain('unpadded hex of length 55');
75
+ expect(printedMessage).not.toContain(' at ');
76
+ expect(exitSpy).toHaveBeenCalledWith(1);
77
+ });
78
+ it('should rethrow unexpected errors so they are not silently swallowed', () => {
79
+ vi.mocked(credentials.resolveCredentialRefs).mockImplementation(() => {
80
+ throw new Error('something else broke');
81
+ });
82
+ expect(() => loadCredentialRefs()).toThrow('something else broke');
83
+ });
84
+ });
@@ -7,11 +7,10 @@ import { existsSync } from 'node:fs';
7
7
  import { resolve } from 'node:path';
8
8
  import * as dotenv from 'dotenv';
9
9
  import debugFactory from 'debug';
10
- import { config } from '#config.js';
11
10
  const debug = debugFactory('output-cli:env-loader');
12
11
  export function loadEnvironment() {
13
12
  const cwd = process.cwd();
14
- const envFile = config.envFile;
13
+ const envFile = process.env.OUTPUT_CLI_ENV || '.env';
15
14
  const envPath = resolve(cwd, envFile);
16
15
  if (!existsSync(envPath)) {
17
16
  debug(`Warning: Env file not found: ${envPath}`);