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 CHANGED
@@ -1,16 +1,66 @@
1
- # SpecShield CLI
1
+ # 🚀 SpecShield — OpenAPI Diff & Breaking Change Detection CLI
2
2
 
3
- > Compare OpenAPI specs and detect breaking changes — designed for CI/CD and local developer workflows.
3
+ ![npm](https://img.shields.io/npm/v/specshield)
4
+ ![downloads](https://img.shields.io/npm/dw/specshield)
5
+ ![license](https://img.shields.io/badge/license-MIT-blue)
6
+ ![node](https://img.shields.io/badge/node-%3E%3D18-green)
4
7
 
5
- ## Features
8
+ Compare OpenAPI and Swagger specs, detect breaking changes, and fail CI before incompatible API changes reach production.
6
9
 
7
- - Detect breaking changes, additions, and modifications between two OpenAPI specs
8
- - Support YAML and JSON specs
9
- - CI/CD-ready with exit code control (`--fail-on-breaking`)
10
- - Config file support (`.specshield.yml`)
11
- - JSON output for machine parsing
12
- - Ignore list to suppress known changes
13
- - Placeholder remote mode for future SaaS backend integration
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.0",
4
- "description": "CLI tool to compare OpenAPI specs and detect breaking changes",
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": ["openapi", "api", "breaking-changes", "cli", "specshield"],
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": ["bin", "src", "README.md", "LICENSE"],
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": ["**/tests/**/*.test.js"],
32
- "collectCoverageFrom": ["src/**/*.js"]
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');
@@ -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-url <url>', 'Remote API endpoint for comparison')
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 { loadSpec } = require('../core/loadSpec');
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 || (options.remote && options.remote.url);
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 remote: ${url}`;
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: { 'Content-Type': 'application/json' } }
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
- remoteUrl: cliOpts.remoteUrl || (config.remote && config.remote.enabled ? config.remote.url : null),
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 };