post-api-sync 0.1.1 → 0.1.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,8 +1,8 @@
1
- # api-sync
1
+ # post-api-sync
2
2
 
3
3
  Sync your API code directly to Postman and Insomnia collections.
4
4
 
5
- `api-sync` extracts endpoint definitions, parameters, and validation schemas (Zod, Class Validator) from your Hono, Express, or NestJS code and generates ready-to-use collections. It can also push changes directly to Postman Cloud.
5
+ `post-api-sync` extracts endpoint definitions, parameters, and validation schemas (Zod, Class Validator) from your Hono, Express, or NestJS code and generates ready-to-use collections. It can also push changes directly to Postman Cloud.
6
6
 
7
7
  ## Features
8
8
 
@@ -18,32 +18,32 @@ Sync your API code directly to Postman and Insomnia collections.
18
18
  ## Installation
19
19
 
20
20
  ```bash
21
- npm install -g api-sync
21
+ npm install -g post-api-sync
22
22
  # or use via npx
23
- npx api-sync --help
23
+ npx post-api-sync --help
24
24
  ```
25
25
 
26
26
  ## Quick Start
27
27
 
28
28
  1. **Initialize configuration**:
29
29
  ```bash
30
- npx api-sync init
30
+ npx post-api-sync init
31
31
  ```
32
- This will create an `api-sync.config.js` file in your project root.
32
+ This will create an `post-api-sync.config.js` file in your project root.
33
33
 
34
34
  2. **Run extraction**:
35
35
  ```bash
36
- npx api-sync sync
36
+ npx post-api-sync sync
37
37
  ```
38
38
 
39
39
  3. **Watch for changes**:
40
40
  ```bash
41
- npx api-sync watch
41
+ npx post-api-sync watch
42
42
  ```
43
43
 
44
44
  ## Configuration
45
45
 
46
- The `api-sync.config.js` file allows you to customize the tool's behavior:
46
+ The `post-api-sync.config.js` file allows you to customize the tool's behavior:
47
47
 
48
48
  ```javascript
49
49
  module.exports = {
@@ -59,6 +59,11 @@ module.exports = {
59
59
  baseUrl: 'http://localhost:3000'
60
60
  },
61
61
 
62
+ organization: {
63
+ // 'folder' (default) or 'tags'
64
+ groupBy: 'folder'
65
+ },
66
+
62
67
  output: {
63
68
  postman: {
64
69
  enabled: true,
@@ -67,14 +72,18 @@ module.exports = {
67
72
  apiKey: process.env.POSTMAN_API_KEY,
68
73
  collectionId: process.env.POSTMAN_COLLECTION_ID
69
74
  },
70
- insomnia: {
71
- enabled: true,
72
- outputPath: './insomnia_collection.json'
73
- }
75
+ // ...
74
76
  }
75
77
  };
76
78
  ```
77
79
 
80
+ ### Environment Variables
81
+ You can use a `.env` file in your project root to store sensitive keys:
82
+ ```bash
83
+ POSTMAN_API_KEY=your-api-key
84
+ POSTMAN_COLLECTION_ID=your-collection-uid
85
+ ```
86
+
78
87
  ## Postman Cloud Sync
79
88
 
80
89
  You can push your generated collection directly to Postman without manual importing.
@@ -84,7 +93,11 @@ You can push your generated collection directly to Postman without manual import
84
93
  3. Run the sync command:
85
94
 
86
95
  ```bash
87
- npx api-sync sync --postman-key <YOUR_KEY> --postman-id <COLLECTION_UID>
96
+ # Finds keys in .env or config
97
+ npx post-api-sync
98
+
99
+ # Or specify manually
100
+ npx post-api-sync sync --postman-key <YOUR_KEY> --postman-id <COLLECTION_UID>
88
101
  ```
89
102
 
90
103
  Or set them in your config/environment variables to use with `watch` mode.
@@ -1,17 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ require('dotenv').config();
3
4
  const { program } = require('commander');
4
5
  const { initConfig } = require('../src/init');
5
6
  const { syncOnce } = require('../src/sync');
6
7
  const { watchMode } = require('../src/watch');
7
8
 
8
9
  program
9
- .name('api-sync')
10
- .description('Sync Postman and Insomnia collections from API code');
10
+ .name('post-api-sync')
11
+ .description('Sync Postman and Insomnia collections from API code')
12
+ .action(async () => {
13
+ // Default behavior: run sync
14
+ await syncOnce();
15
+ });
11
16
 
12
17
  program
13
18
  .command('init')
14
- .description('Create api-sync.config.js')
19
+ .description('Create post-api-sync.config.js')
15
20
  .option('--cwd <path>', 'Project root for config')
16
21
  .action(async (opts) => {
17
22
  await initConfig({ baseDir: opts.cwd });
package/package.json CHANGED
@@ -1,21 +1,26 @@
1
1
  {
2
2
  "name": "post-api-sync",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Sync Postman and Insomnia collections from API code",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/EzekielMisgae/post-api-sync.git"
8
+ },
5
9
  "type": "commonjs",
6
10
  "bin": {
7
- "api-sync": "bin/api-sync.js"
11
+ "post-api-sync": "bin/post-api-sync.js"
8
12
  },
9
13
  "scripts": {
10
- "sync": "node bin/api-sync.js sync",
11
- "watch": "node bin/api-sync.js watch",
12
- "init": "node bin/api-sync.js init"
14
+ "sync": "node bin/post-api-sync.js sync",
15
+ "watch": "node bin/post-api-sync.js watch",
16
+ "init": "node bin/post-api-sync.js init"
13
17
  },
14
18
  "dependencies": {
15
19
  "@babel/parser": "^7.24.0",
16
20
  "@babel/traverse": "^7.24.0",
17
21
  "chokidar": "^3.6.0",
18
22
  "commander": "^12.0.0",
23
+ "dotenv": "^17.2.4",
19
24
  "fast-glob": "^3.3.2",
20
25
  "fs-extra": "^11.2.0",
21
26
  "inquirer": "^9.2.0",
@@ -28,7 +28,7 @@ function buildInsomniaCollection(endpoints, config) {
28
28
  _type: 'export',
29
29
  __export_format: 4,
30
30
  __export_date: new Date().toISOString(),
31
- __export_source: 'api-sync',
31
+ __export_source: 'post-api-sync',
32
32
  resources
33
33
  };
34
34
  }
@@ -1,12 +1,18 @@
1
1
  const { nanoid } = require('nanoid');
2
+ const path = require('path');
2
3
  const { toPostmanPath, splitPath, extractPathParams } = require('../utils');
3
4
 
4
5
  function buildPostmanCollection(endpoints, config) {
5
6
  const name = (config.output && config.output.postman && config.output.postman.collectionName) || 'API Collection';
6
- const baseUrl = config.sources.baseUrl || 'http://localhost:3000';
7
- const groupBy = (config.organization && config.organization.groupBy) || 'tags';
7
+ const baseUrl = (config.sources && config.sources.baseUrl) || 'http://localhost:3000';
8
+ const groupBy = (config.organization && config.organization.groupBy) || 'folder';
8
9
 
9
- const items = groupBy === 'tags' ? buildTaggedItems(endpoints) : endpoints.map(buildItem);
10
+ let items;
11
+ if (groupBy === 'folder') {
12
+ items = buildFolderItems(endpoints);
13
+ } else {
14
+ items = buildTaggedItems(endpoints);
15
+ }
10
16
 
11
17
  return {
12
18
  info: {
@@ -19,6 +25,63 @@ function buildPostmanCollection(endpoints, config) {
19
25
  };
20
26
  }
21
27
 
28
+ function buildFolderItems(endpoints) {
29
+ const root = { item: [], _folders: {} };
30
+
31
+ for (const endpoint of endpoints) {
32
+ // Determine module name from file path
33
+ // e.g. /abs/path/to/src/modules/health/routes.ts -> Health
34
+ // e.g. /abs/path/to/src/modules/orders/routes/order.routes.ts -> Orders
35
+ let moduleName = 'General';
36
+ if (endpoint.filePath) {
37
+ const parts = endpoint.filePath.split(path.sep);
38
+ const filename = parts[parts.length - 1];
39
+ const parent = parts[parts.length - 2];
40
+ const grandparent = parts[parts.length - 3];
41
+
42
+ if (filename === 'routes.ts' || filename.endsWith('.routes.ts')) {
43
+ if (parent === 'routes' && grandparent) {
44
+ moduleName = capitalize(grandparent);
45
+ } else {
46
+ moduleName = capitalize(parent);
47
+ }
48
+ } else {
49
+ // Fallback: try to find a meaningful parent
50
+ // If parent is 'routes', go up one
51
+ if (parent === 'routes' && grandparent) {
52
+ moduleName = capitalize(grandparent);
53
+ } else {
54
+ moduleName = capitalize(parent);
55
+ }
56
+ }
57
+ }
58
+
59
+ if (!root._folders[moduleName]) {
60
+ const folder = { name: moduleName, item: [], _folders: {} };
61
+ root.item.push(folder);
62
+ root._folders[moduleName] = folder;
63
+ }
64
+
65
+ root._folders[moduleName].item.push(buildItem(endpoint));
66
+ }
67
+
68
+ return cleanFolders(root.item);
69
+ }
70
+
71
+ function capitalize(str) {
72
+ if (!str) return str;
73
+ return str.charAt(0).toUpperCase() + str.slice(1);
74
+ }
75
+
76
+ function cleanFolders(items) {
77
+ // Remove temporary _folders property
78
+ for (const item of items) {
79
+ if (item._folders) delete item._folders;
80
+ if (item.item) cleanFolders(item.item);
81
+ }
82
+ return items;
83
+ }
84
+
22
85
  function buildTaggedItems(endpoints) {
23
86
  const groups = new Map();
24
87
  for (const endpoint of endpoints) {
@@ -57,10 +120,10 @@ function buildItem(endpoint) {
57
120
  },
58
121
  body: hasBody
59
122
  ? {
60
- mode: 'raw',
61
- raw: JSON.stringify(example || {}, null, 2),
62
- options: { raw: { language: 'json' } }
63
- }
123
+ mode: 'raw',
124
+ raw: JSON.stringify(example || {}, null, 2),
125
+ options: { raw: { language: 'json' } }
126
+ }
64
127
  : undefined
65
128
  },
66
129
  response: []
package/src/config.js CHANGED
@@ -6,7 +6,7 @@ const ALWAYS_EXCLUDE = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*
6
6
  const DEFAULT_CONFIG = {
7
7
  framework: 'auto',
8
8
  sources: {
9
- include: ['src/**/*.{ts,js,tsx,jsx}'],
9
+ include: ['src/**/routes.ts', 'src/**/*.routes.ts'],
10
10
  exclude: ['**/*.spec.ts', '**/*.test.ts', 'node_modules/**', 'dist/**', 'build/**'],
11
11
  baseUrl: 'http://localhost:3000'
12
12
  },
@@ -28,7 +28,7 @@ const DEFAULT_CONFIG = {
28
28
  markDeprecated: true
29
29
  },
30
30
  organization: {
31
- groupBy: 'tags'
31
+ groupBy: 'folder'
32
32
  }
33
33
  };
34
34
 
@@ -41,18 +41,18 @@ async function resolveConfigPath(configPath, baseDir) {
41
41
  const stat = await fs.stat(abs);
42
42
  if (stat.isDirectory()) {
43
43
  cwd = abs;
44
- return { path: path.resolve(cwd, 'api-sync.config.js'), baseDir: cwd };
44
+ return { path: path.resolve(cwd, 'post-api-sync.config.js'), baseDir: cwd };
45
45
  }
46
46
  return { path: abs, baseDir: path.dirname(abs) };
47
47
  }
48
48
  if (!path.extname(abs)) {
49
49
  cwd = abs;
50
- return { path: path.resolve(cwd, 'api-sync.config.js'), baseDir: cwd };
50
+ return { path: path.resolve(cwd, 'post-api-sync.config.js'), baseDir: cwd };
51
51
  }
52
52
  return { path: abs, baseDir: cwd };
53
53
  }
54
54
 
55
- return { path: path.resolve(cwd, 'api-sync.config.js'), baseDir: cwd };
55
+ return { path: path.resolve(cwd, 'post-api-sync.config.js'), baseDir: cwd };
56
56
  }
57
57
 
58
58
  async function loadConfig(configPath, baseDir) {
@@ -57,32 +57,64 @@ function flattenPostmanItems(items, out = []) {
57
57
  return out;
58
58
  }
59
59
 
60
- function rebuildFromFlat(templateItems, flatItems) {
61
- // If template has folders, rebuild preserving folder names based on name prefix match.
62
- if (!templateItems.some((i) => i.item)) return flatItems;
63
- const folderMap = new Map();
64
- for (const folder of templateItems) {
65
- if (folder.item) {
66
- folderMap.set(folder.name, { name: folder.name, item: [] });
67
- }
60
+ function rebuildFromFlat(templateItems, mergedItems) {
61
+ // Create a map of merged items for quick lookup
62
+ const mergedMap = new Map();
63
+ for (const item of mergedItems) {
64
+ const key = keyFromPostmanItem(item);
65
+ if (key) mergedMap.set(key, item);
68
66
  }
69
- for (const item of flatItems) {
70
- const tag = inferFolderName(item.name);
71
- if (folderMap.has(tag)) {
72
- folderMap.get(tag).item.push(item);
67
+
68
+ // 1. Rebuild the structure based on the template (newly generated structure)
69
+ const rebuilt = replaceItemsRecursive(templateItems, mergedMap);
70
+
71
+ // 2. Identify items that are in mergedItems (existing+deprecated) but NOT in template
72
+ // These are effectively "extra" items (old manual items, or deprecated ones)
73
+ // We want to keep them, usually appended to the root
74
+ const usedKeys = new Set();
75
+ traverseKeys(rebuilt, usedKeys);
76
+
77
+ const leftovers = mergedItems.filter(item => {
78
+ const key = keyFromPostmanItem(item);
79
+ return key && !usedKeys.has(key);
80
+ });
81
+
82
+ // Add leftovers to the root
83
+ return [...rebuilt, ...leftovers];
84
+ }
85
+
86
+ function replaceItemsRecursive(items, mergedMap) {
87
+ return items.map(item => {
88
+ if (item.item) {
89
+ // It's a folder
90
+ return { ...item, item: replaceItemsRecursive(item.item, mergedMap) };
91
+ }
92
+ // It's a request
93
+ const key = keyFromPostmanItem(item);
94
+ if (key && mergedMap.has(key)) {
95
+ // Return the merged version (which has description etc from existing)
96
+ // But we must preserve the new name/folder context if it changed?
97
+ // Actually mergedMap has the MERGED item.
98
+ // If the folder structure changed, 'item' here is from template, so it's in the right place.
99
+ // We just want the content from mergedMap.
100
+ return mergedMap.get(key);
101
+ }
102
+ return item;
103
+ });
104
+ }
105
+
106
+ function traverseKeys(items, keySet) {
107
+ for (const item of items) {
108
+ if (item.item) {
109
+ traverseKeys(item.item, keySet);
73
110
  } else {
74
- if (!folderMap.has('General')) folderMap.set('General', { name: 'General', item: [] });
75
- folderMap.get('General').item.push(item);
111
+ const key = keyFromPostmanItem(item);
112
+ if (key) keySet.add(key);
76
113
  }
77
114
  }
78
- return Array.from(folderMap.values());
79
115
  }
80
116
 
81
- function inferFolderName(name) {
82
- const match = name.match(/^[A-Z]+\s+\S+/);
83
- if (match) return 'General';
84
- return 'General';
85
- }
117
+
86
118
 
87
119
  function keyFromPostmanItem(item) {
88
120
  if (!item || !item.request) return null;
package/src/sync.js CHANGED
@@ -13,9 +13,9 @@ async function syncOnce({ configPath, baseDir, postmanKey, postmanId } = {}) {
13
13
  try {
14
14
  const { config, baseDir: resolvedBase } = await loadConfig(configPath, baseDir);
15
15
  const cwd = resolvedBase || process.cwd();
16
- // Use CLI args or config values
17
- const pmKey = postmanKey || (config.output && config.output.postman && config.output.postman.apiKey);
18
- const pmId = postmanId || (config.output && config.output.postman && config.output.postman.collectionId);
16
+ // Use CLI args, env vars, or config values
17
+ const pmKey = postmanKey || process.env.POSTMAN_API_KEY || (config.output && config.output.postman && config.output.postman.apiKey);
18
+ const pmId = postmanId || process.env.POSTMAN_COLLECTION_ID || (config.output && config.output.postman && config.output.postman.collectionId);
19
19
 
20
20
  const include = normalizeIncludePatterns(config.sources.include || [], cwd);
21
21
  const exclude = Array.from(new Set([
@@ -60,6 +60,7 @@ async function syncOnce({ configPath, baseDir, postmanKey, postmanId } = {}) {
60
60
  await fs.outputJson(outPath, merged, { spaces: 2 });
61
61
  success(`Postman collection written to ${path.relative(process.cwd(), outPath)}`);
62
62
 
63
+ // Auto-sync checks
63
64
  if (pmKey && pmId) {
64
65
  info(`Pushing to Postman Cloud (ID: ${pmId})...`);
65
66
  try {
@@ -68,6 +69,11 @@ async function syncOnce({ configPath, baseDir, postmanKey, postmanId } = {}) {
68
69
  } catch (err) {
69
70
  error(`Failed to sync to Postman Cloud: ${err.message}`);
70
71
  }
72
+ } else if (pmKey || pmId) {
73
+ // Partial keys present
74
+ warn('Skipping Postman Cloud sync: Missing API Key or Collection ID.');
75
+ if (!pmKey) warn(' -> POSTMAN_API_KEY is missing');
76
+ if (!pmId) warn(' -> POSTMAN_COLLECTION_ID is missing');
71
77
  }
72
78
  }
73
79
 
package/src/watch.js CHANGED
@@ -3,7 +3,7 @@ const { loadConfig, normalizeIncludePatterns, normalizeExcludePatterns, ALWAYS_E
3
3
  const { syncOnce } = require('./sync');
4
4
  const { info } = require('./log');
5
5
 
6
- async function watchMode({ configPath, baseDir } = {}) {
6
+ async function watchMode({ configPath, baseDir, postmanKey, postmanId } = {}) {
7
7
  const { config, baseDir: resolvedBase } = await loadConfig(configPath, baseDir);
8
8
  const cwd = resolvedBase || process.cwd();
9
9
  const include = normalizeIncludePatterns(config.sources.include || [], cwd);
@@ -19,7 +19,7 @@ async function watchMode({ configPath, baseDir } = {}) {
19
19
  const trigger = () => {
20
20
  if (timer) clearTimeout(timer);
21
21
  timer = setTimeout(() => {
22
- syncOnce({ configPath, baseDir: cwd });
22
+ syncOnce({ configPath, baseDir: cwd, postmanKey, postmanId });
23
23
  }, debounceMs);
24
24
  };
25
25