@outputai/cli 0.6.1-next.65cd087.0 → 0.6.1-next.83742db.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.
@@ -80,7 +80,7 @@ services:
80
80
  condition: service_healthy
81
81
  worker:
82
82
  condition: service_healthy
83
- image: outputai/api:${OUTPUT_API_VERSION:-0.6.1-next.65cd087.0}
83
+ image: outputai/api:${OUTPUT_API_VERSION:-0.6.1-next.83742db.0}
84
84
  init: true
85
85
  networks:
86
86
  - main
@@ -5,6 +5,7 @@ export default class CredentialsEdit extends Command {
5
5
  static flags: {
6
6
  environment: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  workflow: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
9
  };
9
10
  run(): Promise<void>;
10
11
  }
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { load as parseYaml } from 'js-yaml';
7
- import { decryptCredentials, writeEncrypted, credentialsExist, resolveCredentialsPath } from '#services/credentials_service.js';
7
+ import { decryptCredentials, writeEncrypted, credentialsExist, resolveCredentialsPath, checkKeyMatchesCredentials, reEncryptKeyMismatchMessage } from '#services/credentials_service.js';
8
8
  export default class CredentialsEdit extends Command {
9
9
  static description = 'Edit encrypted credentials in your $EDITOR';
10
10
  static examples = [
@@ -20,6 +20,11 @@ export default class CredentialsEdit extends Command {
20
20
  workflow: Flags.string({
21
21
  char: 'w',
22
22
  description: 'Target a specific workflow directory'
23
+ }),
24
+ force: Flags.boolean({
25
+ char: 'f',
26
+ description: 'Edit on an empty file and re-encrypt with the current key when it cannot decrypt the existing file (discards existing values)',
27
+ default: false
23
28
  })
24
29
  };
25
30
  async run() {
@@ -32,9 +37,16 @@ export default class CredentialsEdit extends Command {
32
37
  if (!credentialsExist(environment, workflow)) {
33
38
  this.error(`No credentials file found at ${resolveCredentialsPath(environment, workflow)}. Run "output credentials init" first.`);
34
39
  }
40
+ const keyMismatch = checkKeyMatchesCredentials(environment, workflow) === 'mismatch';
41
+ if (keyMismatch && !flags.force) {
42
+ this.error(reEncryptKeyMismatchMessage(resolveCredentialsPath(environment, workflow)));
43
+ }
44
+ if (keyMismatch) {
45
+ this.warn(reEncryptKeyMismatchMessage(resolveCredentialsPath(environment, workflow)));
46
+ }
35
47
  const editorEnv = process.env.EDITOR || process.env.VISUAL || 'vi';
36
48
  const [editorCmd, ...editorArgs] = editorEnv.split(/\s+/);
37
- const plaintext = decryptCredentials(environment, workflow);
49
+ const plaintext = keyMismatch ? '' : decryptCredentials(environment, workflow);
38
50
  const tmpFile = path.join(os.tmpdir(), `output-credentials-${Date.now()}.yml`);
39
51
  try {
40
52
  fs.writeFileSync(tmpFile, plaintext, { mode: 0o600 });
@@ -23,6 +23,8 @@ describe('credentials edit command', () => {
23
23
  beforeEach(() => {
24
24
  vi.clearAllMocks();
25
25
  vi.mocked(credentialsService.credentialsExist).mockReturnValue(true);
26
+ vi.mocked(credentialsService.checkKeyMatchesCredentials).mockReturnValue('match');
27
+ vi.mocked(credentialsService.reEncryptKeyMismatchMessage).mockReturnValue('KEY_MISMATCH_WARNING');
26
28
  vi.mocked(credentialsService.decryptCredentials).mockReturnValue('anthropic:\n api_key: sk-test\n');
27
29
  });
28
30
  afterEach(() => {
@@ -31,13 +33,14 @@ describe('credentials edit command', () => {
31
33
  const createTestCommand = (flags = {}) => {
32
34
  const cmd = new CredentialsEdit([], {});
33
35
  cmd.log = vi.fn();
36
+ cmd.warn = vi.fn();
34
37
  cmd.error = vi.fn((msg) => {
35
38
  throw new Error(msg);
36
39
  });
37
40
  Object.defineProperty(cmd, 'parse', {
38
41
  value: vi.fn().mockResolvedValue({
39
42
  args: {},
40
- flags: { environment: undefined, workflow: undefined, ...flags }
43
+ flags: { environment: undefined, workflow: undefined, force: false, ...flags }
41
44
  }),
42
45
  configurable: true
43
46
  });
@@ -47,9 +50,10 @@ describe('credentials edit command', () => {
47
50
  it('should have correct description', () => {
48
51
  expect(CredentialsEdit.description).toContain('Edit');
49
52
  });
50
- it('should have environment and workflow flags', () => {
53
+ it('should have environment, workflow, and force flags', () => {
51
54
  expect(CredentialsEdit.flags.environment).toBeDefined();
52
55
  expect(CredentialsEdit.flags.workflow).toBeDefined();
56
+ expect(CredentialsEdit.flags.force).toBeDefined();
53
57
  });
54
58
  });
55
59
  describe('command execution', () => {
@@ -71,6 +75,24 @@ describe('credentials edit command', () => {
71
75
  await expect(cmd.run()).rejects.toThrow('No credentials file found');
72
76
  });
73
77
  });
78
+ describe('key mismatch handling', () => {
79
+ it('should error and not decrypt when the key cannot decrypt and --force is absent', async () => {
80
+ vi.mocked(credentialsService.checkKeyMatchesCredentials).mockReturnValue('mismatch');
81
+ const cmd = createTestCommand();
82
+ await expect(cmd.run()).rejects.toThrow('KEY_MISMATCH_WARNING');
83
+ expect(credentialsService.decryptCredentials).not.toHaveBeenCalled();
84
+ expect(credentialsService.writeEncrypted).not.toHaveBeenCalled();
85
+ });
86
+ it('should warn, edit from empty, and re-encrypt when --force is passed', async () => {
87
+ vi.mocked(credentialsService.checkKeyMatchesCredentials).mockReturnValue('mismatch');
88
+ vi.mocked(fs.readFileSync).mockReturnValue('edited: content\n');
89
+ const cmd = createTestCommand({ force: true });
90
+ await cmd.run();
91
+ expect(cmd.warn).toHaveBeenCalledWith('KEY_MISMATCH_WARNING');
92
+ expect(credentialsService.decryptCredentials).not.toHaveBeenCalled();
93
+ expect(credentialsService.writeEncrypted).toHaveBeenCalledWith(undefined, 'edited: content\n', undefined);
94
+ });
95
+ });
74
96
  // Regression: OUT-441 — editing without making changes must not re-encrypt
75
97
  // the file. AES-GCM uses a fresh nonce per encrypt, so an unconditional
76
98
  // re-write produces a new ciphertext and leaves the file dirty in git.
@@ -10,6 +10,7 @@ export default class CredentialsSet extends Command {
10
10
  environment: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
11
  workflow: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  };
14
15
  private confirmOverwrite;
15
16
  run(): Promise<void>;
@@ -2,7 +2,7 @@ import { Args, Command, Flags } from '@oclif/core';
2
2
  import { confirm } from '@inquirer/prompts';
3
3
  import { load as parseYaml, dump as stringifyYaml } from 'js-yaml';
4
4
  import { getErrorMessage } from '#utils/error_utils.js';
5
- import { decryptCredentials, credentialsExist, writeEncrypted, resolveCredentialsPath } from '#services/credentials_service.js';
5
+ import { decryptCredentials, credentialsExist, writeEncrypted, resolveCredentialsPath, checkKeyMatchesCredentials, reEncryptKeyMismatchMessage } from '#services/credentials_service.js';
6
6
  const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
7
7
  const detectPathConflict = (obj, dotPath) => {
8
8
  const parts = dotPath.split('.');
@@ -77,6 +77,11 @@ export default class CredentialsSet extends Command {
77
77
  char: 'y',
78
78
  description: 'Skip confirmation prompts when overwriting a value of a different shape',
79
79
  default: false
80
+ }),
81
+ force: Flags.boolean({
82
+ char: 'f',
83
+ description: 'Re-encrypt with the current key even if it cannot decrypt the existing file (discards existing values)',
84
+ default: false
80
85
  })
81
86
  };
82
87
  async confirmOverwrite(conflict, newPath) {
@@ -100,8 +105,15 @@ export default class CredentialsSet extends Command {
100
105
  if (!credentialsExist(environment, workflow)) {
101
106
  this.error(`No credentials file found at ${resolveCredentialsPath(environment, workflow)}. Run "output credentials init" first.`);
102
107
  }
108
+ const keyMismatch = checkKeyMatchesCredentials(environment, workflow) === 'mismatch';
109
+ if (keyMismatch && !flags.force) {
110
+ this.error(reEncryptKeyMismatchMessage(resolveCredentialsPath(environment, workflow)));
111
+ }
103
112
  try {
104
- const plaintext = decryptCredentials(environment, workflow);
113
+ if (keyMismatch) {
114
+ this.warn(reEncryptKeyMismatchMessage(resolveCredentialsPath(environment, workflow)));
115
+ }
116
+ const plaintext = keyMismatch ? '' : decryptCredentials(environment, workflow);
105
117
  const data = (parseYaml(plaintext) || {});
106
118
  const conflict = detectPathConflict(data, args.path);
107
119
  if (conflict && !flags.yes) {
@@ -26,6 +26,8 @@ describe('credentials set command', () => {
26
26
  beforeEach(() => {
27
27
  vi.clearAllMocks();
28
28
  vi.mocked(credentialsService.credentialsExist).mockReturnValue(true);
29
+ vi.mocked(credentialsService.checkKeyMatchesCredentials).mockReturnValue('match');
30
+ vi.mocked(credentialsService.reEncryptKeyMismatchMessage).mockReturnValue('KEY_MISMATCH_WARNING');
29
31
  vi.mocked(credentialsService.decryptCredentials).mockReturnValue('anthropic:\n api_key: sk-existing\n');
30
32
  vi.mocked(credentialsService.writeEncrypted).mockImplementation(() => { });
31
33
  vi.mocked(confirm).mockResolvedValue(true);
@@ -43,7 +45,7 @@ describe('credentials set command', () => {
43
45
  Object.defineProperty(cmd, 'parse', {
44
46
  value: vi.fn().mockResolvedValue({
45
47
  args: { path: 'anthropic.api_key', value: 'sk-new-key', ...parsedArgs },
46
- flags: { environment: undefined, workflow: undefined, yes: false, ...flags }
48
+ flags: { environment: undefined, workflow: undefined, yes: false, force: false, ...flags }
47
49
  }),
48
50
  configurable: true
49
51
  });
@@ -59,10 +61,11 @@ describe('credentials set command', () => {
59
61
  expect(CredentialsSet.args.value).toBeDefined();
60
62
  expect(CredentialsSet.args.value.required).toBe(true);
61
63
  });
62
- it('should have environment, workflow, and yes flags', () => {
64
+ it('should have environment, workflow, yes, and force flags', () => {
63
65
  expect(CredentialsSet.flags.environment).toBeDefined();
64
66
  expect(CredentialsSet.flags.workflow).toBeDefined();
65
67
  expect(CredentialsSet.flags.yes).toBeDefined();
68
+ expect(CredentialsSet.flags.force).toBeDefined();
66
69
  });
67
70
  });
68
71
  describe('command execution', () => {
@@ -121,6 +124,24 @@ describe('credentials set command', () => {
121
124
  await expect(cmd.run()).rejects.toThrow('Failed to update credentials: Permission denied');
122
125
  });
123
126
  });
127
+ describe('key mismatch handling', () => {
128
+ it('should error and not write when the key cannot decrypt and --force is absent', async () => {
129
+ vi.mocked(credentialsService.checkKeyMatchesCredentials).mockReturnValue('mismatch');
130
+ const cmd = createTestCommand();
131
+ await expect(cmd.run()).rejects.toThrow('KEY_MISMATCH_WARNING');
132
+ expect(credentialsService.decryptCredentials).not.toHaveBeenCalled();
133
+ expect(credentialsService.writeEncrypted).not.toHaveBeenCalled();
134
+ });
135
+ it('should warn, skip decryption, and re-encrypt from empty when --force is passed', async () => {
136
+ vi.mocked(credentialsService.checkKeyMatchesCredentials).mockReturnValue('mismatch');
137
+ const cmd = createTestCommand({}, { force: true });
138
+ await cmd.run();
139
+ expect(cmd.warn).toHaveBeenCalledWith('KEY_MISMATCH_WARNING');
140
+ expect(credentialsService.decryptCredentials).not.toHaveBeenCalled();
141
+ expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
142
+ expect(cmd.log).toHaveBeenCalledWith('Set anthropic.api_key');
143
+ });
144
+ });
124
145
  describe('shape-change confirmation', () => {
125
146
  it('should prompt before converting a primitive value into an object', async () => {
126
147
  vi.mocked(credentialsService.decryptCredentials).mockReturnValue('__PRIMITIVE_AT_X_Y__');
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.6.1-next.65cd087.0"
2
+ "framework": "0.6.1-next.83742db.0"
3
3
  }
@@ -4,6 +4,9 @@ export declare const resolveCredentialsPath: (environment: CredentialsEnvironmen
4
4
  export declare const resolveKeyPath: (environment: CredentialsEnvironment, workflow?: WorkflowTarget) => string;
5
5
  export declare const resolveKey: (environment: CredentialsEnvironment, workflow?: WorkflowTarget) => string;
6
6
  export declare const credentialsExist: (environment: CredentialsEnvironment, workflow?: WorkflowTarget) => boolean;
7
+ export type KeyMatch = 'no_file' | 'match' | 'mismatch';
8
+ export declare const checkKeyMatchesCredentials: (environment: CredentialsEnvironment, workflow?: WorkflowTarget) => KeyMatch;
9
+ export declare const reEncryptKeyMismatchMessage: (credPath: string) => string;
7
10
  export declare const decryptCredentials: (environment: CredentialsEnvironment, workflow?: WorkflowTarget) => string;
8
11
  export declare const writeEncrypted: (environment: CredentialsEnvironment, plaintext: string, workflow?: WorkflowTarget) => void;
9
12
  export declare const initCredentials: (environment: CredentialsEnvironment, workflow?: WorkflowTarget) => {
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { load as parseYaml } from 'js-yaml';
6
6
  import { encrypt, decrypt, generateKey } from '@outputai/credentials';
7
- import { initCredentials, decryptCredentials, writeEncrypted } from './credentials_service.js';
7
+ import { initCredentials, decryptCredentials, writeEncrypted, checkKeyMatchesCredentials } from './credentials_service.js';
8
8
  describe('credentials service integration', () => {
9
9
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'output-creds-'));
10
10
  afterAll(() => {
@@ -123,4 +123,38 @@ describe('credentials service integration', () => {
123
123
  expect(fs.readFileSync(prod.credPath).equals(prodCredBytes)).toBe(true);
124
124
  }));
125
125
  });
126
+ describe('checkKeyMatchesCredentials', () => {
127
+ const withIsolatedProject = (body) => {
128
+ const originalCwd = process.cwd();
129
+ const originalKey = process.env.OUTPUT_CREDENTIALS_KEY;
130
+ delete process.env.OUTPUT_CREDENTIALS_KEY;
131
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'output-creds-keymatch-'));
132
+ process.chdir(projectDir);
133
+ try {
134
+ body();
135
+ }
136
+ finally {
137
+ process.chdir(originalCwd);
138
+ if (originalKey === undefined) {
139
+ delete process.env.OUTPUT_CREDENTIALS_KEY;
140
+ }
141
+ else {
142
+ process.env.OUTPUT_CREDENTIALS_KEY = originalKey;
143
+ }
144
+ fs.rmSync(projectDir, { recursive: true, force: true });
145
+ }
146
+ };
147
+ it('returns "no_file" when no credentials file exists', () => withIsolatedProject(() => {
148
+ expect(checkKeyMatchesCredentials(undefined)).toBe('no_file');
149
+ }));
150
+ it('returns "match" when the current key decrypts the file', () => withIsolatedProject(() => {
151
+ initCredentials(undefined);
152
+ expect(checkKeyMatchesCredentials(undefined)).toBe('match');
153
+ }));
154
+ it('returns "mismatch" when the key cannot decrypt the file', () => withIsolatedProject(() => {
155
+ const { keyPath } = initCredentials(undefined);
156
+ fs.writeFileSync(keyPath, generateKey(), { mode: 0o600 });
157
+ expect(checkKeyMatchesCredentials(undefined)).toBe('mismatch');
158
+ }));
159
+ });
126
160
  });
@@ -33,6 +33,25 @@ export const resolveKey = (environment, workflow) => {
33
33
  throw new Error(`No key found. Set ${envVar} env var or create ${keyPath}.`);
34
34
  };
35
35
  export const credentialsExist = (environment, workflow) => fs.existsSync(resolveCredentialsPath(environment, workflow));
36
+ export const checkKeyMatchesCredentials = (environment, workflow) => {
37
+ const credPath = resolveCredentialsPath(environment, workflow);
38
+ if (!fs.existsSync(credPath)) {
39
+ return 'no_file';
40
+ }
41
+ try {
42
+ const key = resolveKey(environment, workflow);
43
+ const ciphertext = fs.readFileSync(credPath, 'utf8').trim();
44
+ decrypt(ciphertext, key);
45
+ return 'match';
46
+ }
47
+ catch {
48
+ return 'mismatch';
49
+ }
50
+ };
51
+ export const reEncryptKeyMismatchMessage = (credPath) => `The current credentials key cannot decrypt ${credPath}. ` +
52
+ 'Continuing will re-encrypt the file with a different key — the wrong key ' +
53
+ '(OUTPUT_CREDENTIALS_KEY env var or key file) may be in use, and the existing values will be discarded. ' +
54
+ 'Re-run with --force to proceed anyway.';
36
55
  export const decryptCredentials = (environment, workflow) => {
37
56
  const key = resolveKey(environment, workflow);
38
57
  const credPath = resolveCredentialsPath(environment, workflow);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.6.1-next.65cd087.0",
3
+ "version": "0.6.1-next.83742db.0",
4
4
  "description": "CLI for Output.ai workflow generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,9 +36,9 @@
36
36
  "semver": "7.7.4",
37
37
  "undici": "8.1.0",
38
38
  "yaml": "^2.8.3",
39
- "@outputai/evals": "0.6.1-next.65cd087.0",
40
- "@outputai/credentials": "0.6.1-next.65cd087.0",
41
- "@outputai/llm": "0.6.1-next.65cd087.0"
39
+ "@outputai/credentials": "0.6.1-next.83742db.0",
40
+ "@outputai/evals": "0.6.1-next.83742db.0",
41
+ "@outputai/llm": "0.6.1-next.83742db.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/cli-progress": "3.11.6",