post-api-sync 0.1.1

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/src/log.js ADDED
@@ -0,0 +1,37 @@
1
+ const kleur = require('kleur');
2
+
3
+ const isTty = process.stdout.isTTY;
4
+
5
+ function info(message) {
6
+ if (isTty) {
7
+ console.log(kleur.cyan(message));
8
+ } else {
9
+ console.log(message);
10
+ }
11
+ }
12
+
13
+ function warn(message) {
14
+ if (isTty) {
15
+ console.warn(kleur.yellow(message));
16
+ } else {
17
+ console.warn(message);
18
+ }
19
+ }
20
+
21
+ function error(message) {
22
+ if (isTty) {
23
+ console.error(kleur.red(message));
24
+ } else {
25
+ console.error(message);
26
+ }
27
+ }
28
+
29
+ function success(message) {
30
+ if (isTty) {
31
+ console.log(kleur.green(message));
32
+ } else {
33
+ console.log(message);
34
+ }
35
+ }
36
+
37
+ module.exports = { info, warn, error, success };
@@ -0,0 +1,77 @@
1
+ const { buildInsomniaCollection } = require('../collection/insomnia');
2
+ const { normalizePath } = require('../utils');
3
+
4
+ function mergeInsomniaCollection(endpoints, config, existing) {
5
+ const generated = buildInsomniaCollection(endpoints, config);
6
+ if (!existing || !existing.resources) return generated;
7
+
8
+ const generatedRequests = generated.resources.filter((r) => r._type === 'request');
9
+ const existingRequests = existing.resources.filter((r) => r._type === 'request');
10
+
11
+ const existingMap = new Map();
12
+ for (const req of existingRequests) {
13
+ const key = keyFromRequest(req);
14
+ if (key) existingMap.set(key, req);
15
+ }
16
+
17
+ const mergedRequests = [];
18
+ for (const req of generatedRequests) {
19
+ const key = keyFromRequest(req);
20
+ if (key && existingMap.has(key)) {
21
+ mergedRequests.push(mergeRequest(req, existingMap.get(key)));
22
+ } else {
23
+ mergedRequests.push(req);
24
+ }
25
+ }
26
+
27
+ if (config.merge && config.merge.markDeprecated) {
28
+ for (const [key, req] of existingMap.entries()) {
29
+ if (!generatedRequests.find((r) => keyFromRequest(r) === key)) {
30
+ const deprecated = { ...req };
31
+ deprecated.name = deprecated.name.startsWith('[DEPRECATED]')
32
+ ? deprecated.name
33
+ : `[DEPRECATED] ${deprecated.name}`;
34
+ mergedRequests.push(deprecated);
35
+ }
36
+ }
37
+ }
38
+
39
+ const nonRequestResources = generated.resources.filter((r) => r._type !== 'request');
40
+
41
+ return {
42
+ ...existing,
43
+ resources: [...nonRequestResources, ...mergedRequests]
44
+ };
45
+ }
46
+
47
+ function keyFromRequest(req) {
48
+ if (!req || !req.method || !req.url) return null;
49
+ const url = req.url.replace(/\{\{\s*_.baseUrl\s*\}\}/i, '');
50
+ return `${req.method.toUpperCase()} ${normalizePath(url)}`;
51
+ }
52
+
53
+ function mergeRequest(newReq, existingReq) {
54
+ return {
55
+ ...existingReq,
56
+ name: newReq.name || existingReq.name,
57
+ method: newReq.method || existingReq.method,
58
+ url: newReq.url || existingReq.url,
59
+ headers: mergeHeaders(newReq.headers || [], existingReq.headers || []),
60
+ body: newReq.body || existingReq.body
61
+ };
62
+ }
63
+
64
+ function mergeHeaders(codeHeaders, existingHeaders) {
65
+ const map = new Map();
66
+ for (const h of existingHeaders) {
67
+ if (!h || !h.name) continue;
68
+ map.set(h.name.toLowerCase(), h);
69
+ }
70
+ for (const h of codeHeaders) {
71
+ if (!h || !h.name) continue;
72
+ map.set(h.name.toLowerCase(), h);
73
+ }
74
+ return Array.from(map.values());
75
+ }
76
+
77
+ module.exports = { mergeInsomniaCollection };
@@ -0,0 +1,141 @@
1
+ const { buildPostmanCollection } = require('../collection/postman');
2
+ const { toPostmanPath, normalizePath } = require('../utils');
3
+
4
+ function mergePostmanCollection(endpoints, config, existing) {
5
+ const generated = buildPostmanCollection(endpoints, config);
6
+ if (!existing || !existing.item) return generated;
7
+
8
+ const newItems = flattenPostmanItems(generated.item);
9
+ const existingItems = flattenPostmanItems(existing.item);
10
+
11
+ const existingMap = new Map();
12
+ for (const item of existingItems) {
13
+ const key = keyFromPostmanItem(item);
14
+ if (key) existingMap.set(key, item);
15
+ }
16
+
17
+ const mergedItems = [];
18
+ for (const item of newItems) {
19
+ const key = keyFromPostmanItem(item);
20
+ if (key && existingMap.has(key)) {
21
+ mergedItems.push(mergePostmanItem(item, existingMap.get(key)));
22
+ } else {
23
+ mergedItems.push(item);
24
+ }
25
+ }
26
+
27
+ if (config.merge && config.merge.markDeprecated) {
28
+ for (const [key, item] of existingMap.entries()) {
29
+ if (!newItems.find((i) => keyFromPostmanItem(i) === key)) {
30
+ const deprecated = { ...item };
31
+ deprecated.name = deprecated.name.startsWith('[DEPRECATED]')
32
+ ? deprecated.name
33
+ : `[DEPRECATED] ${deprecated.name}`;
34
+ mergedItems.push(deprecated);
35
+ }
36
+ }
37
+ }
38
+
39
+ const rebuilt = rebuildFromFlat(generated.item, mergedItems);
40
+
41
+ return {
42
+ ...existing,
43
+ info: existing.info || generated.info,
44
+ variable: generated.variable,
45
+ item: rebuilt
46
+ };
47
+ }
48
+
49
+ function flattenPostmanItems(items, out = []) {
50
+ for (const item of items) {
51
+ if (item.item && Array.isArray(item.item)) {
52
+ flattenPostmanItems(item.item, out);
53
+ } else {
54
+ out.push(item);
55
+ }
56
+ }
57
+ return out;
58
+ }
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
+ }
68
+ }
69
+ for (const item of flatItems) {
70
+ const tag = inferFolderName(item.name);
71
+ if (folderMap.has(tag)) {
72
+ folderMap.get(tag).item.push(item);
73
+ } else {
74
+ if (!folderMap.has('General')) folderMap.set('General', { name: 'General', item: [] });
75
+ folderMap.get('General').item.push(item);
76
+ }
77
+ }
78
+ return Array.from(folderMap.values());
79
+ }
80
+
81
+ function inferFolderName(name) {
82
+ const match = name.match(/^[A-Z]+\s+\S+/);
83
+ if (match) return 'General';
84
+ return 'General';
85
+ }
86
+
87
+ function keyFromPostmanItem(item) {
88
+ if (!item || !item.request) return null;
89
+ const method = item.request.method || '';
90
+ const url = item.request.url || {};
91
+ let raw = '';
92
+ if (typeof url === 'string') raw = url;
93
+ if (url.raw) raw = url.raw;
94
+ let path = '';
95
+ if (raw) {
96
+ path = raw.replace(/\{\{\s*baseUrl\s*\}\}/i, '');
97
+ } else if (Array.isArray(url.path)) {
98
+ path = `/${url.path.join('/')}`;
99
+ }
100
+ path = toPostmanPath(path || '/');
101
+ return `${method.toUpperCase()} ${normalizePath(path)}`;
102
+ }
103
+
104
+ function mergePostmanItem(newItem, existingItem) {
105
+ const merged = { ...existingItem };
106
+ merged.name = newItem.name || existingItem.name;
107
+
108
+ const newRequest = newItem.request || {};
109
+ const existingRequest = existingItem.request || {};
110
+
111
+ const headers = mergeHeaders(newRequest.header || [], existingRequest.header || []);
112
+
113
+ merged.request = {
114
+ ...existingRequest,
115
+ method: newRequest.method || existingRequest.method,
116
+ url: newRequest.url || existingRequest.url,
117
+ header: headers,
118
+ body: newRequest.body || existingRequest.body
119
+ };
120
+
121
+ if (newItem.description && !existingItem.description) {
122
+ merged.description = newItem.description;
123
+ }
124
+
125
+ return merged;
126
+ }
127
+
128
+ function mergeHeaders(codeHeaders, existingHeaders) {
129
+ const map = new Map();
130
+ for (const h of existingHeaders) {
131
+ if (!h || !h.key) continue;
132
+ map.set(h.key.toLowerCase(), h);
133
+ }
134
+ for (const h of codeHeaders) {
135
+ if (!h || !h.key) continue;
136
+ map.set(h.key.toLowerCase(), h);
137
+ }
138
+ return Array.from(map.values());
139
+ }
140
+
141
+ module.exports = { mergePostmanCollection };
@@ -0,0 +1,46 @@
1
+ const https = require('https');
2
+
3
+ function pushToPostman(collectionJson, apiKey, collectionUid) {
4
+ return new Promise((resolve, reject) => {
5
+ const data = JSON.stringify({
6
+ collection: collectionJson
7
+ });
8
+
9
+ const options = {
10
+ hostname: 'api.getpostman.com',
11
+ port: 443,
12
+ path: `/collections/${collectionUid}`,
13
+ method: 'PUT',
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ 'X-Api-Key': apiKey,
17
+ 'Content-Length': Buffer.byteLength(data)
18
+ }
19
+ };
20
+
21
+ const req = https.request(options, (res) => {
22
+ let responseBody = '';
23
+
24
+ res.on('data', (chunk) => {
25
+ responseBody += chunk;
26
+ });
27
+
28
+ res.on('end', () => {
29
+ if (res.statusCode >= 200 && res.statusCode < 300) {
30
+ resolve(JSON.parse(responseBody));
31
+ } else {
32
+ reject(new Error(`Postman API Error: ${res.statusCode} - ${responseBody}`));
33
+ }
34
+ });
35
+ });
36
+
37
+ req.on('error', (e) => {
38
+ reject(e);
39
+ });
40
+
41
+ req.write(data);
42
+ req.end();
43
+ });
44
+ }
45
+
46
+ module.exports = { pushToPostman };
package/src/sync.js ADDED
@@ -0,0 +1,99 @@
1
+ const fs = require('fs-extra');
2
+ const fg = require('fast-glob');
3
+ const path = require('path');
4
+ const { loadConfig, ensureAbsolute, normalizeIncludePatterns, normalizeExcludePatterns, ALWAYS_EXCLUDE } = require('./config');
5
+ const { extractAllEndpoints } = require('./extract');
6
+ const { mergePostmanCollection } = require('./merge/postman');
7
+ const { mergeInsomniaCollection } = require('./merge/insomnia');
8
+ const postmanCloud = require('./sync/postman-cloud');
9
+ const { info, warn, success, error } = require('./log');
10
+ const { isJsOrTs } = require('./utils');
11
+
12
+ async function syncOnce({ configPath, baseDir, postmanKey, postmanId } = {}) {
13
+ try {
14
+ const { config, baseDir: resolvedBase } = await loadConfig(configPath, baseDir);
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);
19
+
20
+ const include = normalizeIncludePatterns(config.sources.include || [], cwd);
21
+ const exclude = Array.from(new Set([
22
+ ...normalizeExcludePatterns(config.sources.exclude || [], cwd),
23
+ ...ALWAYS_EXCLUDE
24
+ ]));
25
+
26
+ const files = await fg(include, { ignore: exclude, dot: false, cwd, absolute: true });
27
+ const jsTsFiles = files.filter(isJsOrTs);
28
+
29
+ info(`Scanning ${jsTsFiles.length} file(s)...`);
30
+ if (jsTsFiles.length === 0) {
31
+ warn(`No files matched. cwd=${cwd}`);
32
+ warn(`include=${include.join(', ')}`);
33
+ warn(`exclude=${exclude.join(', ')}`);
34
+ }
35
+
36
+ let extracted = [];
37
+ try {
38
+ extracted = await extractAllEndpoints(jsTsFiles, config.framework);
39
+ if (extracted.length === 0 && config.framework && config.framework !== 'auto') {
40
+ const fallback = await extractAllEndpoints(jsTsFiles, 'auto');
41
+ if (fallback.length) {
42
+ warn(`No endpoints found for framework=${config.framework}. Falling back to auto-detect.`);
43
+ extracted = fallback;
44
+ }
45
+ }
46
+ } catch (err) {
47
+ warn(`Extraction failed: ${err.message || err}`);
48
+ }
49
+
50
+ const unique = new Map();
51
+ for (const e of extracted) unique.set(e.key, e);
52
+
53
+ const finalEndpoints = Array.from(unique.values());
54
+ info(`Found ${finalEndpoints.length} endpoint(s)`);
55
+
56
+ if (config.output && config.output.postman && config.output.postman.enabled) {
57
+ const outPath = ensureAbsolute(config.output.postman.outputPath, cwd);
58
+ const existing = await readJsonIfExists(outPath);
59
+ const merged = mergePostmanCollection(finalEndpoints, config, existing);
60
+ await fs.outputJson(outPath, merged, { spaces: 2 });
61
+ success(`Postman collection written to ${path.relative(process.cwd(), outPath)}`);
62
+
63
+ if (pmKey && pmId) {
64
+ info(`Pushing to Postman Cloud (ID: ${pmId})...`);
65
+ try {
66
+ await postmanCloud.pushToPostman(merged, pmKey, pmId);
67
+ success('Successfully synced to Postman Cloud!');
68
+ } catch (err) {
69
+ error(`Failed to sync to Postman Cloud: ${err.message}`);
70
+ }
71
+ }
72
+ }
73
+
74
+ if (config.output && config.output.insomnia && config.output.insomnia.enabled) {
75
+ const outPath = ensureAbsolute(config.output.insomnia.outputPath, cwd);
76
+ const existing = await readJsonIfExists(outPath);
77
+ const merged = mergeInsomniaCollection(finalEndpoints, config, existing);
78
+ await fs.outputJson(outPath, merged, { spaces: 2 });
79
+ success(`Insomnia collection written to ${path.relative(process.cwd(), outPath)}`);
80
+ }
81
+ } catch (err) {
82
+ error(err.stack || err.message || String(err));
83
+ process.exitCode = 1;
84
+ }
85
+ }
86
+
87
+ async function readJsonIfExists(filePath) {
88
+ if (await fs.pathExists(filePath)) {
89
+ try {
90
+ return await fs.readJson(filePath);
91
+ } catch (err) {
92
+ warn(`Failed to read existing collection at ${filePath}: ${err.message || err}`);
93
+ return null;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ module.exports = { syncOnce };
package/src/utils.js ADDED
@@ -0,0 +1,58 @@
1
+ const path = require('path');
2
+
3
+ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head'];
4
+
5
+ function normalizePath(p) {
6
+ if (!p) return '/';
7
+ let out = p.trim();
8
+ if (!out.startsWith('/')) out = `/${out}`;
9
+ out = out.replace(/\/+/g, '/');
10
+ if (out.length > 1 && out.endsWith('/')) out = out.slice(0, -1);
11
+ return out;
12
+ }
13
+
14
+ function joinPaths(base, child) {
15
+ const a = normalizePath(base || '/');
16
+ const b = normalizePath(child || '/');
17
+ if (a === '/') return b;
18
+ if (b === '/') return a;
19
+ return normalizePath(`${a}/${b}`);
20
+ }
21
+
22
+ function extractPathParams(p) {
23
+ const params = new Set();
24
+ const colonMatches = p.match(/:([A-Za-z0-9_]+)/g) || [];
25
+ for (const m of colonMatches) params.add(m.slice(1));
26
+ const braceMatches = p.match(/{([A-Za-z0-9_]+)}/g) || [];
27
+ for (const m of braceMatches) params.add(m.slice(1, -1));
28
+ return Array.from(params);
29
+ }
30
+
31
+ function toPostmanPath(p) {
32
+ return p.replace(/{(\w+)}/g, ':$1');
33
+ }
34
+
35
+ function splitPath(p) {
36
+ return normalizePath(p).split('/').filter(Boolean);
37
+ }
38
+
39
+ function toKey(method, pathStr) {
40
+ return `${method.toUpperCase()} ${normalizePath(pathStr)}`;
41
+ }
42
+
43
+ function isJsOrTs(filePath) {
44
+ if (filePath.endsWith('.d.ts') || filePath.endsWith('.d.tsx')) return false;
45
+ const ext = path.extname(filePath).toLowerCase();
46
+ return ext === '.js' || ext === '.ts' || ext === '.jsx' || ext === '.tsx';
47
+ }
48
+
49
+ module.exports = {
50
+ HTTP_METHODS,
51
+ normalizePath,
52
+ joinPaths,
53
+ extractPathParams,
54
+ toPostmanPath,
55
+ splitPath,
56
+ toKey,
57
+ isJsOrTs
58
+ };
package/src/watch.js ADDED
@@ -0,0 +1,37 @@
1
+ const chokidar = require('chokidar');
2
+ const { loadConfig, normalizeIncludePatterns, normalizeExcludePatterns, ALWAYS_EXCLUDE } = require('./config');
3
+ const { syncOnce } = require('./sync');
4
+ const { info } = require('./log');
5
+
6
+ async function watchMode({ configPath, baseDir } = {}) {
7
+ const { config, baseDir: resolvedBase } = await loadConfig(configPath, baseDir);
8
+ const cwd = resolvedBase || process.cwd();
9
+ const include = normalizeIncludePatterns(config.sources.include || [], cwd);
10
+ const exclude = Array.from(new Set([
11
+ ...normalizeExcludePatterns(config.sources.exclude || [], cwd),
12
+ ...ALWAYS_EXCLUDE
13
+ ]));
14
+ const debounceMs = (config.watch && config.watch.debounce) || 300;
15
+
16
+ info(`Watching for changes...`);
17
+
18
+ let timer = null;
19
+ const trigger = () => {
20
+ if (timer) clearTimeout(timer);
21
+ timer = setTimeout(() => {
22
+ syncOnce({ configPath, baseDir: cwd });
23
+ }, debounceMs);
24
+ };
25
+
26
+ const watcher = chokidar.watch(include, {
27
+ ignored: exclude,
28
+ ignoreInitial: true,
29
+ cwd
30
+ });
31
+
32
+ watcher.on('add', trigger);
33
+ watcher.on('change', trigger);
34
+ watcher.on('unlink', trigger);
35
+ }
36
+
37
+ module.exports = { watchMode };