post-api-sync 0.1.2 → 0.1.7

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
@@ -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,6 +93,10 @@ You can push your generated collection directly to Postman without manual import
84
93
  3. Run the sync command:
85
94
 
86
95
  ```bash
96
+ # Finds keys in .env or config
97
+ npx post-api-sync
98
+
99
+ # Or specify manually
87
100
  npx post-api-sync sync --postman-key <YOUR_KEY> --postman-id <COLLECTION_UID>
88
101
  ```
89
102
 
@@ -1,5 +1,6 @@
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');
@@ -7,7 +8,11 @@ const { watchMode } = require('../src/watch');
7
8
 
8
9
  program
9
10
  .name('post-api-sync')
10
- .description('Sync Postman and Insomnia collections from API code');
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')
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "post-api-sync",
3
- "version": "0.1.2",
3
+ "version": "0.1.7",
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
11
  "post-api-sync": "bin/post-api-sync.js"
@@ -16,6 +20,7 @@
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",
@@ -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
 
@@ -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