@outputai/cli 0.3.3-next.e8eff63.0 → 0.4.1-dev.622e67b.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.
@@ -77,7 +77,7 @@ services:
77
77
  condition: service_healthy
78
78
  worker:
79
79
  condition: service_healthy
80
- image: outputai/api:${OUTPUT_API_VERSION:-0.3.3-next.e8eff63.0}
80
+ image: outputai/api:${OUTPUT_API_VERSION:-0.4.1-dev.622e67b.0}
81
81
  init: true
82
82
  networks:
83
83
  - main
@@ -48,6 +48,13 @@ export default class CredentialsEdit extends Command {
48
48
  const edited = fs.readFileSync(tmpFile, 'utf8');
49
49
  // Validate YAML before saving
50
50
  parseYaml(edited);
51
+ // AES-GCM uses a fresh nonce per encrypt, so re-encrypting unchanged
52
+ // plaintext produces a new ciphertext. Skip the write when nothing
53
+ // changed to avoid leaving the file dirty in git.
54
+ if (edited === plaintext) {
55
+ this.log('No changes detected. Credentials unchanged.');
56
+ return;
57
+ }
51
58
  writeEncrypted(environment, edited, workflow);
52
59
  this.log('Credentials saved successfully.');
53
60
  }
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+ import fs from 'node:fs';
3
4
  import * as credentialsService from '#services/credentials_service.js';
4
5
  import CredentialsEdit from './edit.js';
5
6
  vi.mock('#services/credentials_service.js');
@@ -70,4 +71,26 @@ describe('credentials edit command', () => {
70
71
  await expect(cmd.run()).rejects.toThrow('No credentials file found');
71
72
  });
72
73
  });
74
+ // Regression: OUT-441 — editing without making changes must not re-encrypt
75
+ // the file. AES-GCM uses a fresh nonce per encrypt, so an unconditional
76
+ // re-write produces a new ciphertext and leaves the file dirty in git.
77
+ describe('no-op edit (OUT-441)', () => {
78
+ it('should NOT call writeEncrypted when the editor returns unchanged plaintext', async () => {
79
+ const original = 'anthropic:\n api_key: sk-test\n';
80
+ vi.mocked(credentialsService.decryptCredentials).mockReturnValue(original);
81
+ vi.mocked(fs.readFileSync).mockReturnValue(original);
82
+ const cmd = createTestCommand();
83
+ await cmd.run();
84
+ expect(credentialsService.writeEncrypted).not.toHaveBeenCalled();
85
+ });
86
+ it('should still call writeEncrypted when the editor returns modified plaintext', async () => {
87
+ const original = 'anthropic:\n api_key: sk-test\n';
88
+ const modified = 'anthropic:\n api_key: sk-NEW\n';
89
+ vi.mocked(credentialsService.decryptCredentials).mockReturnValue(original);
90
+ vi.mocked(fs.readFileSync).mockReturnValue(modified);
91
+ const cmd = createTestCommand();
92
+ await cmd.run();
93
+ expect(credentialsService.writeEncrypted).toHaveBeenCalledWith(undefined, modified, undefined);
94
+ });
95
+ });
73
96
  });
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.3.3-next.e8eff63.0"
2
+ "framework": "0.4.1-dev.622e67b.0"
3
3
  }
@@ -4,6 +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
8
  describe('credentials service integration', () => {
8
9
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'output-creds-'));
9
10
  afterAll(() => {
@@ -63,4 +64,63 @@ describe('credentials service integration', () => {
63
64
  expect(parsed.openai.api_key).toBe('sk-openai-789');
64
65
  });
65
66
  });
67
+ // Reproduction: editing the dev credential must not touch the production
68
+ // credential. Reported as a partial bug report — these tests pin the
69
+ // cross-environment isolation invariant.
70
+ describe('multi-environment isolation', () => {
71
+ const withIsolatedProject = (body) => {
72
+ const originalCwd = process.cwd();
73
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'output-creds-multi-env-'));
74
+ process.chdir(projectDir);
75
+ try {
76
+ body();
77
+ }
78
+ finally {
79
+ process.chdir(originalCwd);
80
+ fs.rmSync(projectDir, { recursive: true, force: true });
81
+ }
82
+ };
83
+ it('editing the development credential does not modify the production credential', () => withIsolatedProject(() => {
84
+ const prod = initCredentials('production');
85
+ const dev = initCredentials('development');
86
+ const prodKeyBefore = fs.readFileSync(prod.keyPath);
87
+ const prodCredBefore = fs.readFileSync(prod.credPath);
88
+ const devKeyBefore = fs.readFileSync(dev.keyPath);
89
+ writeEncrypted('development', 'anthropic:\n api_key: sk-DEV-EDITED\nopenai:\n api_key: sk-DEV-EDITED-2\n');
90
+ // Production must be byte-for-byte identical
91
+ expect(fs.readFileSync(prod.keyPath).equals(prodKeyBefore)).toBe(true);
92
+ expect(fs.readFileSync(prod.credPath).equals(prodCredBefore)).toBe(true);
93
+ // Dev key must not have been regenerated
94
+ expect(fs.readFileSync(dev.keyPath).equals(devKeyBefore)).toBe(true);
95
+ // Dev credential should now contain the edited plaintext
96
+ expect(decryptCredentials('development')).toContain('sk-DEV-EDITED');
97
+ // Production credential should still be the original template (empty values)
98
+ const prodPlain = decryptCredentials('production');
99
+ const prodParsed = parseYaml(prodPlain);
100
+ expect(prodParsed.anthropic.api_key).toBe('');
101
+ expect(prodParsed.openai.api_key).toBe('');
102
+ }));
103
+ it('editing production does not bleed into development', () => withIsolatedProject(() => {
104
+ const prod = initCredentials('production');
105
+ const dev = initCredentials('development');
106
+ const devKeyBefore = fs.readFileSync(dev.keyPath);
107
+ const devCredBefore = fs.readFileSync(dev.credPath);
108
+ writeEncrypted('production', 'anthropic:\n api_key: sk-PROD-EDITED\nopenai:\n api_key: sk-PROD-EDITED-2\n');
109
+ expect(fs.readFileSync(dev.keyPath).equals(devKeyBefore)).toBe(true);
110
+ expect(fs.readFileSync(dev.credPath).equals(devCredBefore)).toBe(true);
111
+ expect(decryptCredentials('production')).toContain('sk-PROD-EDITED');
112
+ expect(decryptCredentials('development')).not.toContain('sk-PROD-EDITED');
113
+ // Sanity: prod and dev still resolved to different paths/keys
114
+ expect(prod.keyPath).not.toBe(dev.keyPath);
115
+ expect(prod.credPath).not.toBe(dev.credPath);
116
+ }));
117
+ it('initializing a second environment does not mutate the first', () => withIsolatedProject(() => {
118
+ const prod = initCredentials('production');
119
+ const prodKeyBytes = fs.readFileSync(prod.keyPath);
120
+ const prodCredBytes = fs.readFileSync(prod.credPath);
121
+ initCredentials('development');
122
+ expect(fs.readFileSync(prod.keyPath).equals(prodKeyBytes)).toBe(true);
123
+ expect(fs.readFileSync(prod.credPath).equals(prodCredBytes)).toBe(true);
124
+ }));
125
+ });
66
126
  });
@@ -23,7 +23,7 @@
23
23
  "engines": {
24
24
  "node": ">=24.3.0"
25
25
  },
26
- "output": {
26
+ "outputai": {
27
27
  "hookFiles": ["node_modules/@outputai/credentials/dist/hooks.js"]
28
28
  }
29
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.3.3-next.e8eff63.0",
3
+ "version": "0.4.1-dev.622e67b.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/credentials": "0.3.3-next.e8eff63.0",
40
- "@outputai/evals": "0.3.3-next.e8eff63.0",
41
- "@outputai/llm": "0.3.3-next.e8eff63.0"
39
+ "@outputai/credentials": "0.4.1-dev.622e67b.0",
40
+ "@outputai/evals": "0.4.1-dev.622e67b.0",
41
+ "@outputai/llm": "0.4.1-dev.622e67b.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/cli-progress": "3.11.6",