specshield 1.0.0 → 1.0.3
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.
- package/README.md +60 -10
- package/package.json +38 -6
- package/src/cli.js +4 -0
- package/src/commands/compare.js +34 -7
- package/src/commands/login.js +54 -0
- package/src/commands/logout.js +16 -0
- package/src/config/localConfig.js +38 -0
package/README.md
CHANGED
|
@@ -1,16 +1,66 @@
|
|
|
1
|
-
# SpecShield CLI
|
|
1
|
+
# 🚀 SpecShield — OpenAPI Diff & Breaking Change Detection CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
Compare OpenAPI and Swagger specs, detect breaking changes, and fail CI before incompatible API changes reach production.
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
10
|
+
|
|
11
|
+
## 📌 What is SpecShield?
|
|
12
|
+
|
|
13
|
+
SpecShield is a CLI tool that compares two OpenAPI/Swagger specifications and detects:
|
|
14
|
+
|
|
15
|
+
- ❌ Breaking changes
|
|
16
|
+
- ➕ Additions
|
|
17
|
+
- 🔄 Modifications
|
|
18
|
+
|
|
19
|
+
It is designed for:
|
|
20
|
+
- CI/CD pipelines
|
|
21
|
+
- Backend developers
|
|
22
|
+
- API governance teams
|
|
23
|
+
- Local development workflows
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## ❗ Why SpecShield?
|
|
28
|
+
|
|
29
|
+
API changes can silently break:
|
|
30
|
+
- Mobile apps
|
|
31
|
+
- Frontend clients
|
|
32
|
+
- Partner integrations
|
|
33
|
+
- Internal microservices
|
|
34
|
+
|
|
35
|
+
Manual API review is:
|
|
36
|
+
- ❌ Error-prone
|
|
37
|
+
- ❌ Time-consuming
|
|
38
|
+
- ❌ Not scalable
|
|
39
|
+
|
|
40
|
+
👉 SpecShield solves this by automating API contract validation.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 🎯 Key Benefits
|
|
45
|
+
|
|
46
|
+
- 🚫 Prevent breaking API releases
|
|
47
|
+
- ⚙️ Enforce API contract checks in CI/CD
|
|
48
|
+
- 🔍 Compare OpenAPI specs automatically
|
|
49
|
+
- 📊 Generate machine-readable reports
|
|
50
|
+
- 🧩 Integrate easily with existing workflows
|
|
51
|
+
- 🛑 Fail builds when breaking changes are detected
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## ✨ Features
|
|
56
|
+
|
|
57
|
+
- Detect breaking changes, additions, and modifications
|
|
58
|
+
- Support YAML and JSON OpenAPI specs
|
|
59
|
+
- CI/CD-ready with exit codes
|
|
60
|
+
- JSON output for automation
|
|
61
|
+
- `.specshield.yml` config support
|
|
62
|
+
- Ignore list for known changes
|
|
63
|
+
- Future SaaS-ready remote mode
|
|
14
64
|
|
|
15
65
|
## Installation
|
|
16
66
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specshield",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "CLI
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "CLI to compare OpenAPI/Swagger specs and detect breaking changes for CI/CD pipelines and local developer workflows.",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"specshield": "./bin/specshield.js"
|
|
@@ -12,9 +12,37 @@
|
|
|
12
12
|
"test:watch": "jest --watch",
|
|
13
13
|
"lint": "eslint src tests --ext .js"
|
|
14
14
|
},
|
|
15
|
-
"keywords": [
|
|
15
|
+
"keywords": [
|
|
16
|
+
"openapi",
|
|
17
|
+
"swagger",
|
|
18
|
+
"openapi-diff",
|
|
19
|
+
"swagger-diff",
|
|
20
|
+
"api-diff",
|
|
21
|
+
"api-breaking-changes",
|
|
22
|
+
"breaking-change-detection",
|
|
23
|
+
"contract-testing",
|
|
24
|
+
"api-contract",
|
|
25
|
+
"api-versioning",
|
|
26
|
+
"api-governance",
|
|
27
|
+
"ci-cd",
|
|
28
|
+
"devops",
|
|
29
|
+
"github-actions",
|
|
30
|
+
"automation",
|
|
31
|
+
"cli",
|
|
32
|
+
"developer-tools",
|
|
33
|
+
"yaml",
|
|
34
|
+
"json",
|
|
35
|
+
"schema-diff",
|
|
36
|
+
"rest-api",
|
|
37
|
+
"openapi-cli"
|
|
38
|
+
],
|
|
16
39
|
"license": "MIT",
|
|
17
|
-
"files": [
|
|
40
|
+
"files": [
|
|
41
|
+
"bin",
|
|
42
|
+
"src",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
18
46
|
"dependencies": {
|
|
19
47
|
"axios": "^1.6.7",
|
|
20
48
|
"chalk": "^4.1.2",
|
|
@@ -28,8 +56,12 @@
|
|
|
28
56
|
},
|
|
29
57
|
"jest": {
|
|
30
58
|
"testEnvironment": "node",
|
|
31
|
-
"testMatch": [
|
|
32
|
-
|
|
59
|
+
"testMatch": [
|
|
60
|
+
"**/tests/**/*.test.js"
|
|
61
|
+
],
|
|
62
|
+
"collectCoverageFrom": [
|
|
63
|
+
"src/**/*.js"
|
|
64
|
+
]
|
|
33
65
|
},
|
|
34
66
|
"engines": {
|
|
35
67
|
"node": ">=20.0.0"
|
package/src/cli.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
const { Command } = require('commander');
|
|
4
4
|
const { version } = require('../package.json');
|
|
5
5
|
const compareCommand = require('./commands/compare');
|
|
6
|
+
const loginCommand = require('./commands/login');
|
|
7
|
+
const logoutCommand = require('./commands/logout');
|
|
6
8
|
|
|
7
9
|
const program = new Command();
|
|
8
10
|
|
|
@@ -12,6 +14,8 @@ program
|
|
|
12
14
|
.version(version);
|
|
13
15
|
|
|
14
16
|
program.addCommand(compareCommand);
|
|
17
|
+
program.addCommand(loginCommand);
|
|
18
|
+
program.addCommand(logoutCommand);
|
|
15
19
|
|
|
16
20
|
program.parseAsync(process.argv).catch((err) => {
|
|
17
21
|
const logger = require('./utils/logger');
|
package/src/commands/compare.js
CHANGED
|
@@ -14,6 +14,9 @@ const { loadConfig } = require('../core/configLoader');
|
|
|
14
14
|
const { resolveExitCode } = require('../core/exitCode');
|
|
15
15
|
const logger = require('../utils/logger');
|
|
16
16
|
const fsExtra = require('fs-extra');
|
|
17
|
+
const { getStoredApiKey } = require('../config/localConfig');
|
|
18
|
+
|
|
19
|
+
const HOSTED_API_URL = 'https://api.specshield.io';
|
|
17
20
|
|
|
18
21
|
const compare = new Command('compare');
|
|
19
22
|
|
|
@@ -28,7 +31,9 @@ compare
|
|
|
28
31
|
.option('--config <path>', 'Path to .specshield.yml config file')
|
|
29
32
|
.option('--ignore <change>', 'Ignore a specific change string (repeatable)', collect, [])
|
|
30
33
|
.option('--severity <level>', 'Minimum severity level: info | warning | error', 'error')
|
|
31
|
-
.option('--remote
|
|
34
|
+
.option('--remote', 'Use the SpecShield hosted compare API')
|
|
35
|
+
.option('--api-key <key>', 'API key for hosted mode (overrides env and stored config)')
|
|
36
|
+
.option('--remote-url <url>', 'Override the hosted API base URL')
|
|
32
37
|
.option('--timeout <ms>', 'Request timeout for remote mode (ms)', '10000')
|
|
33
38
|
.action(async (base, target, opts) => {
|
|
34
39
|
try {
|
|
@@ -38,11 +43,25 @@ compare
|
|
|
38
43
|
// Merge config with CLI options (CLI wins)
|
|
39
44
|
const options = mergeOptions(config, opts);
|
|
40
45
|
|
|
46
|
+
// Resolve API key for remote mode (precedence: --api-key > env > stored config > .specshield.yml)
|
|
47
|
+
if (options.remote || options.remoteUrl || (config.remote && config.remote.enabled)) {
|
|
48
|
+
options.resolvedApiKey = opts.apiKey
|
|
49
|
+
|| process.env.SPECSHIELD_API_KEY
|
|
50
|
+
|| await getStoredApiKey()
|
|
51
|
+
|| (config.remote && config.remote.apiKey)
|
|
52
|
+
|| null;
|
|
53
|
+
|
|
54
|
+
if (!options.resolvedApiKey) {
|
|
55
|
+
logger.error('No API key found. Run: specshield login --api-key <KEY>');
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
const spinner = options.json ? null : ora('Loading specs...').start();
|
|
42
61
|
|
|
43
62
|
let result;
|
|
44
63
|
|
|
45
|
-
if (options.remoteUrl || (config.remote && config.remote.enabled)) {
|
|
64
|
+
if (options.remote || options.remoteUrl || (config.remote && config.remote.enabled)) {
|
|
46
65
|
result = await runRemoteComparison(base, target, options, spinner);
|
|
47
66
|
} else {
|
|
48
67
|
result = await runLocalComparison(base, target, options, spinner);
|
|
@@ -107,22 +126,29 @@ async function runLocalComparison(base, target, options, spinner) {
|
|
|
107
126
|
|
|
108
127
|
async function runRemoteComparison(base, target, options, spinner) {
|
|
109
128
|
const axios = require('axios');
|
|
110
|
-
const {
|
|
129
|
+
const { version } = require('../../package.json');
|
|
111
130
|
|
|
112
131
|
if (spinner) spinner.text = 'Loading specs for remote comparison...';
|
|
113
132
|
const baseRaw = await loadSpec(base);
|
|
114
133
|
const targetRaw = await loadSpec(target);
|
|
115
134
|
|
|
116
|
-
const url = options.remoteUrl ||
|
|
135
|
+
const url = options.remoteUrl || HOSTED_API_URL + '/compare';
|
|
117
136
|
const timeout = parseInt(options.timeout, 10) || 10000;
|
|
118
137
|
|
|
119
|
-
if (spinner) spinner.text = `Sending to
|
|
138
|
+
if (spinner) spinner.text = `Sending to hosted API...`;
|
|
139
|
+
|
|
140
|
+
const headers = {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
'X-Api-Key': options.resolvedApiKey,
|
|
143
|
+
'X-SpecShield-Client': 'cli',
|
|
144
|
+
'X-SpecShield-Version': version,
|
|
145
|
+
};
|
|
120
146
|
|
|
121
147
|
try {
|
|
122
148
|
const response = await axios.post(
|
|
123
149
|
url,
|
|
124
150
|
{ baseSpec: baseRaw, targetSpec: targetRaw },
|
|
125
|
-
{ timeout, headers
|
|
151
|
+
{ timeout, headers }
|
|
126
152
|
);
|
|
127
153
|
return response.data;
|
|
128
154
|
} catch (err) {
|
|
@@ -167,7 +193,8 @@ function mergeOptions(config, cliOpts) {
|
|
|
167
193
|
...(config.ignore || []),
|
|
168
194
|
],
|
|
169
195
|
severity: cliOpts.severity || config.severity || 'error',
|
|
170
|
-
|
|
196
|
+
remote: cliOpts.remote || (config.remote && config.remote.enabled) || false,
|
|
197
|
+
remoteUrl: cliOpts.remoteUrl || null,
|
|
171
198
|
timeout: cliOpts.timeout || (config.remote && config.remote.timeout) || 10000,
|
|
172
199
|
};
|
|
173
200
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const ora = require('ora');
|
|
6
|
+
const axios = require('axios');
|
|
7
|
+
const { setStoredApiKey, CONFIG_PATH } = require('../config/localConfig');
|
|
8
|
+
|
|
9
|
+
const HOSTED_API_URL = 'https://api.specshield.io';
|
|
10
|
+
|
|
11
|
+
const login = new Command('login');
|
|
12
|
+
|
|
13
|
+
login
|
|
14
|
+
.description('Authenticate with the SpecShield hosted API using your API key')
|
|
15
|
+
.requiredOption('--api-key <key>', 'Your SpecShield API key (starts with ss_)')
|
|
16
|
+
.option('--api-url <url>', 'Override the hosted API base URL', HOSTED_API_URL)
|
|
17
|
+
.action(async (opts) => {
|
|
18
|
+
const spinner = ora('Validating API key...').start();
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const response = await axios.post(
|
|
22
|
+
`${opts.apiUrl}/auth/validate-api-key`,
|
|
23
|
+
{},
|
|
24
|
+
{
|
|
25
|
+
headers: { 'X-Api-Key': opts.apiKey },
|
|
26
|
+
timeout: 10000,
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (!response.data.valid) {
|
|
31
|
+
spinner.fail(chalk.red('API key is invalid.'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await setStoredApiKey(opts.apiKey);
|
|
36
|
+
|
|
37
|
+
spinner.succeed(chalk.green('Logged in successfully.'));
|
|
38
|
+
console.log('');
|
|
39
|
+
console.log(` ${chalk.bold('Customer:')} ${response.data.name}`);
|
|
40
|
+
console.log(` ${chalk.bold('Plan:')} ${response.data.plan}`);
|
|
41
|
+
console.log(` ${chalk.gray('Config:')} ${CONFIG_PATH}`);
|
|
42
|
+
console.log('');
|
|
43
|
+
console.log(chalk.gray(' Run: specshield compare base.yaml target.yaml --remote'));
|
|
44
|
+
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const msg = err.response
|
|
47
|
+
? `Validation failed (${err.response.status}): ${JSON.stringify(err.response.data)}`
|
|
48
|
+
: `Connection error: ${err.message}`;
|
|
49
|
+
spinner.fail(chalk.red(msg));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
module.exports = login;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const { clearStoredApiKey } = require('../config/localConfig');
|
|
6
|
+
|
|
7
|
+
const logout = new Command('logout');
|
|
8
|
+
|
|
9
|
+
logout
|
|
10
|
+
.description('Remove your stored SpecShield API key')
|
|
11
|
+
.action(async () => {
|
|
12
|
+
await clearStoredApiKey();
|
|
13
|
+
console.log(chalk.green('Logged out. API key removed from local config.'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
module.exports = logout;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const fsExtra = require('fs-extra');
|
|
6
|
+
|
|
7
|
+
const CONFIG_PATH = path.join(os.homedir(), '.specshield', 'config.json');
|
|
8
|
+
|
|
9
|
+
async function loadLocalConfig() {
|
|
10
|
+
try {
|
|
11
|
+
return await fsExtra.readJson(CONFIG_PATH);
|
|
12
|
+
} catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function saveLocalConfig(data) {
|
|
18
|
+
await fsExtra.outputJson(CONFIG_PATH, data, { spaces: 2 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function getStoredApiKey() {
|
|
22
|
+
const config = await loadLocalConfig();
|
|
23
|
+
return config.apiKey || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function setStoredApiKey(apiKey) {
|
|
27
|
+
const config = await loadLocalConfig();
|
|
28
|
+
config.apiKey = apiKey;
|
|
29
|
+
await saveLocalConfig(config);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function clearStoredApiKey() {
|
|
33
|
+
const config = await loadLocalConfig();
|
|
34
|
+
delete config.apiKey;
|
|
35
|
+
await saveLocalConfig(config);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { loadLocalConfig, saveLocalConfig, getStoredApiKey, setStoredApiKey, clearStoredApiKey, CONFIG_PATH };
|