@latentforce/shift 1.0.8 → 1.0.10
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 +198 -26
- package/build/cli/commands/init.js +90 -58
- package/build/cli/commands/start.js +36 -68
- package/build/cli/commands/status.js +17 -7
- package/build/cli/commands/update-drg.js +188 -0
- package/build/daemon/tools-executor.js +19 -86
- package/build/index.js +144 -15
- package/build/mcp-server.js +105 -8
- package/build/utils/api-client.js +70 -35
- package/build/utils/auth-resolver.js +184 -0
- package/build/utils/prompts.js +45 -0
- package/build/utils/shiftignore.js +108 -0
- package/build/utils/tree-scanner.js +11 -2
- package/package.json +2 -1
package/build/mcp-server.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { version } = require('../package.json');
|
|
4
7
|
const BASE_URL = process.env.SHIFT_BACKEND_URL || "https://dev-shift-lite.latentforce.ai";
|
|
5
8
|
function getProjectIdFromEnv() {
|
|
6
9
|
const projectId = process.env.SHIFT_PROJECT_ID;
|
|
@@ -17,6 +20,13 @@ function resolveProjectId(args) {
|
|
|
17
20
|
return fromArgs;
|
|
18
21
|
return getProjectIdFromEnv();
|
|
19
22
|
}
|
|
23
|
+
/** Normalize file paths: backslash → forward slash, strip leading ./ and / */
|
|
24
|
+
function normalizePath(filePath) {
|
|
25
|
+
let p = filePath.replace(/\\/g, '/');
|
|
26
|
+
p = p.replace(/^\.\//, '');
|
|
27
|
+
p = p.replace(/^\//, '');
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
20
30
|
// helper
|
|
21
31
|
async function callBackendAPI(endpoint, data) {
|
|
22
32
|
try {
|
|
@@ -29,20 +39,107 @@ async function callBackendAPI(endpoint, data) {
|
|
|
29
39
|
});
|
|
30
40
|
if (!response.ok) {
|
|
31
41
|
const text = await response.text();
|
|
42
|
+
// Provide actionable error messages
|
|
43
|
+
if (response.status === 404) {
|
|
44
|
+
if (text.includes('Knowledge graph not found') || text.includes('knowledge graph')) {
|
|
45
|
+
throw new Error("No knowledge graph exists for this project. Run 'shift-cli update-drg' to build it.");
|
|
46
|
+
}
|
|
47
|
+
if (text.includes('File not found') || text.includes('not found in')) {
|
|
48
|
+
// Extract path from error if possible
|
|
49
|
+
const pathMatch = text.match(/['"]([^'"]+)['"]/);
|
|
50
|
+
const filePath = pathMatch ? pathMatch[1] : 'the requested file';
|
|
51
|
+
throw new Error(`File '${filePath}' not found in knowledge graph. Possible causes:\n` +
|
|
52
|
+
` 1. The path may be incorrect (check casing and slashes)\n` +
|
|
53
|
+
` 2. The file was added after the last 'shift-cli update-drg'\n` +
|
|
54
|
+
` 3. The file type is not supported (only JS/TS files are indexed)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
32
57
|
throw new Error(`API call failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
|
|
33
58
|
}
|
|
34
59
|
return await response.json();
|
|
35
60
|
}
|
|
36
61
|
catch (error) {
|
|
37
|
-
console.error(`Error calling ${endpoint}:`, error);
|
|
38
62
|
throw error;
|
|
39
63
|
}
|
|
40
64
|
}
|
|
65
|
+
/** Format file_summary response as readable markdown */
|
|
66
|
+
function formatFileSummary(data) {
|
|
67
|
+
const lines = [];
|
|
68
|
+
lines.push(`## File: ${data.path}`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
lines.push('### Summary');
|
|
71
|
+
lines.push(data.summary || 'No summary available.');
|
|
72
|
+
if (data.parent_summaries?.length > 0) {
|
|
73
|
+
lines.push('');
|
|
74
|
+
lines.push('### Directory Context');
|
|
75
|
+
for (const parent of data.parent_summaries) {
|
|
76
|
+
lines.push(`- **${parent.path}**: ${parent.summary}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
/** Format dependency response as readable markdown */
|
|
82
|
+
function formatDependencies(data) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
const deps = data.dependencies || [];
|
|
85
|
+
const edges = data.edge_summaries || {};
|
|
86
|
+
lines.push(`## Dependencies: ${data.path}`);
|
|
87
|
+
lines.push('');
|
|
88
|
+
if (deps.length === 0) {
|
|
89
|
+
lines.push('This file has no dependencies.');
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
lines.push(`This file depends on **${deps.length}** file(s):`);
|
|
93
|
+
lines.push('');
|
|
94
|
+
for (const dep of deps) {
|
|
95
|
+
const summary = edges[dep];
|
|
96
|
+
if (summary) {
|
|
97
|
+
lines.push(`- **${dep}**: ${summary}`);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
lines.push(`- **${dep}**`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
106
|
+
/** Format blast_radius response as readable markdown */
|
|
107
|
+
function formatBlastRadius(data) {
|
|
108
|
+
const lines = [];
|
|
109
|
+
const affected = data.affected_files || [];
|
|
110
|
+
lines.push(`## Blast Radius: ${data.path}`);
|
|
111
|
+
lines.push('');
|
|
112
|
+
if (affected.length === 0) {
|
|
113
|
+
lines.push('No other files are affected by changes to this file.');
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
lines.push(`**${affected.length}** file(s) would be affected:`);
|
|
117
|
+
lines.push('');
|
|
118
|
+
// Group by level
|
|
119
|
+
const byLevel = {};
|
|
120
|
+
for (const file of affected) {
|
|
121
|
+
const level = file.level || 1;
|
|
122
|
+
if (!byLevel[level])
|
|
123
|
+
byLevel[level] = [];
|
|
124
|
+
byLevel[level].push(file);
|
|
125
|
+
}
|
|
126
|
+
const levels = Object.keys(byLevel).map(Number).sort((a, b) => a - b);
|
|
127
|
+
for (const level of levels) {
|
|
128
|
+
const label = level === 1 ? 'Level 1 (direct)' : `Level ${level}`;
|
|
129
|
+
lines.push(`### ${label}`);
|
|
130
|
+
for (const file of byLevel[level]) {
|
|
131
|
+
lines.push(`- **${file.path}**: ${file.summary || 'No summary'}`);
|
|
132
|
+
}
|
|
133
|
+
lines.push('');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
41
138
|
export async function startMcpServer() {
|
|
42
139
|
// Create server instance
|
|
43
140
|
const server = new McpServer({
|
|
44
141
|
name: "shift",
|
|
45
|
-
version
|
|
142
|
+
version,
|
|
46
143
|
});
|
|
47
144
|
// Tools:
|
|
48
145
|
// Blast radius
|
|
@@ -56,7 +153,7 @@ export async function startMcpServer() {
|
|
|
56
153
|
}, async (args) => {
|
|
57
154
|
const projectId = resolveProjectId(args);
|
|
58
155
|
const data = await callBackendAPI('/api/v1/mcp/blast-radius', {
|
|
59
|
-
path: args.file_path,
|
|
156
|
+
path: normalizePath(args.file_path),
|
|
60
157
|
project_id: projectId,
|
|
61
158
|
...(args.level != null && { level: args.level }),
|
|
62
159
|
});
|
|
@@ -64,7 +161,7 @@ export async function startMcpServer() {
|
|
|
64
161
|
content: [
|
|
65
162
|
{
|
|
66
163
|
type: "text",
|
|
67
|
-
text:
|
|
164
|
+
text: formatBlastRadius(data)
|
|
68
165
|
},
|
|
69
166
|
],
|
|
70
167
|
};
|
|
@@ -79,14 +176,14 @@ export async function startMcpServer() {
|
|
|
79
176
|
}, async (args) => {
|
|
80
177
|
const projectId = resolveProjectId(args);
|
|
81
178
|
const data = await callBackendAPI('/api/v1/mcp/dependency', {
|
|
82
|
-
path: args.file_path,
|
|
179
|
+
path: normalizePath(args.file_path),
|
|
83
180
|
project_id: projectId,
|
|
84
181
|
});
|
|
85
182
|
return {
|
|
86
183
|
content: [
|
|
87
184
|
{
|
|
88
185
|
type: "text",
|
|
89
|
-
text:
|
|
186
|
+
text: formatDependencies(data)
|
|
90
187
|
},
|
|
91
188
|
],
|
|
92
189
|
};
|
|
@@ -102,7 +199,7 @@ export async function startMcpServer() {
|
|
|
102
199
|
}, async (args) => {
|
|
103
200
|
const projectId = resolveProjectId(args);
|
|
104
201
|
const data = await callBackendAPI('/api/v1/mcp/what-is-this-file', {
|
|
105
|
-
path: args.file_path,
|
|
202
|
+
path: normalizePath(args.file_path),
|
|
106
203
|
project_id: projectId,
|
|
107
204
|
level: args.level ?? 0,
|
|
108
205
|
});
|
|
@@ -110,7 +207,7 @@ export async function startMcpServer() {
|
|
|
110
207
|
content: [
|
|
111
208
|
{
|
|
112
209
|
type: "text",
|
|
113
|
-
text:
|
|
210
|
+
text: formatFileSummary(data)
|
|
114
211
|
},
|
|
115
212
|
],
|
|
116
213
|
};
|
|
@@ -30,49 +30,84 @@ export async function requestGuestKey() {
|
|
|
30
30
|
* Matching extension's fetchProjects function in api-client.js
|
|
31
31
|
*/
|
|
32
32
|
export async function fetchProjects(apiKey) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
throw new Error(text || `HTTP ${response.status}`);
|
|
43
|
-
}
|
|
44
|
-
const data = await response.json();
|
|
45
|
-
return data.projects || [];
|
|
46
|
-
}
|
|
47
|
-
catch (error) {
|
|
48
|
-
console.error('Failed to fetch projects:', error);
|
|
49
|
-
throw new Error(error.message || 'Failed to fetch projects');
|
|
33
|
+
const response = await fetch(`${API_BASE_URL}/api/v1/mcp/projects`, {
|
|
34
|
+
method: 'GET',
|
|
35
|
+
headers: {
|
|
36
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
throw new Error(text || `HTTP ${response.status}`);
|
|
50
42
|
}
|
|
43
|
+
const data = await response.json();
|
|
44
|
+
return data.projects || [];
|
|
51
45
|
}
|
|
52
46
|
/**
|
|
53
47
|
* Send init scan to backend
|
|
54
48
|
* Matching extension's init-scan API call
|
|
55
49
|
*/
|
|
56
50
|
export async function sendInitScan(apiKey, projectId, payload) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
throw new Error(text || `HTTP ${response.status}`);
|
|
69
|
-
}
|
|
70
|
-
return await response.json();
|
|
51
|
+
const response = await fetch(`${API_BASE_URL_ORCH}/api/projects/${projectId}/init-scan`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(payload),
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const text = await response.text();
|
|
61
|
+
throw new Error(text || `HTTP ${response.status}`);
|
|
71
62
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
63
|
+
return await response.json();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Send update-drg request to backend
|
|
67
|
+
* Calls POST /api/v1/mcp/update-drg
|
|
68
|
+
*/
|
|
69
|
+
export async function sendUpdateDrg(apiKey, payload) {
|
|
70
|
+
const response = await fetch(`${API_BASE_URL}/api/v1/mcp/update-drg`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: {
|
|
73
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify(payload),
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
const text = await response.text();
|
|
80
|
+
throw new Error(text || `HTTP ${response.status}`);
|
|
81
|
+
}
|
|
82
|
+
return await response.json();
|
|
83
|
+
}
|
|
84
|
+
export async function fetchMigrationTemplates(apiKey) {
|
|
85
|
+
const response = await fetch(`${API_BASE_URL}/api/v1/mcp/migration-templates`, {
|
|
86
|
+
method: 'GET',
|
|
87
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const text = await response.text();
|
|
91
|
+
throw new Error(text || `HTTP ${response.status}`);
|
|
92
|
+
}
|
|
93
|
+
const data = await response.json();
|
|
94
|
+
return data.templates || [];
|
|
95
|
+
}
|
|
96
|
+
export async function createProject(apiKey, params) {
|
|
97
|
+
const response = await fetch(`${API_BASE_URL}/api/v1/mcp/projects/create`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify(params),
|
|
104
|
+
});
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
throw new Error(text || `HTTP ${response.status}`);
|
|
75
108
|
}
|
|
109
|
+
const data = await response.json();
|
|
110
|
+
return data.project || data;
|
|
76
111
|
}
|
|
77
112
|
/**
|
|
78
113
|
* Fetch project indexing/work status
|
|
@@ -82,7 +117,7 @@ export async function fetchProjectStatus(apiKey, projectId) {
|
|
|
82
117
|
const controller = new AbortController();
|
|
83
118
|
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
|
84
119
|
try {
|
|
85
|
-
const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}/status`, {
|
|
120
|
+
const response = await fetch(`${API_BASE_URL}/api/v1/mcp/projects/${projectId}/status`, {
|
|
86
121
|
method: 'GET',
|
|
87
122
|
headers: {
|
|
88
123
|
'Authorization': `Bearer ${apiKey}`,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { getApiKey, setApiKey, setGuestKey, isGuestKey, setProject, readProjectConfig, } from './config.js';
|
|
3
|
+
import { requestGuestKey, fetchProjects, fetchMigrationTemplates, createProject, } from './api-client.js';
|
|
4
|
+
import { promptApiKey, promptKeyChoice, promptSelectProject, promptProjectName, promptCreateOrSelect, promptSelectTemplate, } from './prompts.js';
|
|
5
|
+
/**
|
|
6
|
+
* Resolve API key from flags, config, or interactive prompts.
|
|
7
|
+
* Guest flow returns both apiKey and projectId (from guest-key endpoint).
|
|
8
|
+
*/
|
|
9
|
+
export async function resolveApiKey(opts = {}) {
|
|
10
|
+
const label = opts.commandLabel || 'Shift';
|
|
11
|
+
const interactive = opts.interactive ?? true;
|
|
12
|
+
// If both --guest and --api-key, api-key wins
|
|
13
|
+
if (opts.guest && opts.apiKey) {
|
|
14
|
+
console.log(`[${label}] ⚠️ Both --guest and --api-key provided; using --api-key.`);
|
|
15
|
+
}
|
|
16
|
+
// 1. Explicit --api-key flag
|
|
17
|
+
if (opts.apiKey) {
|
|
18
|
+
setApiKey(opts.apiKey);
|
|
19
|
+
console.log(`[${label}] ✓ API key saved\n`);
|
|
20
|
+
return { apiKey: opts.apiKey };
|
|
21
|
+
}
|
|
22
|
+
// 2. Existing key in config
|
|
23
|
+
const existingKey = getApiKey();
|
|
24
|
+
if (existingKey) {
|
|
25
|
+
if (isGuestKey()) {
|
|
26
|
+
console.log(`[${label}] ✓ Guest key found\n`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
console.log(`[${label}] ✓ API key found\n`);
|
|
30
|
+
}
|
|
31
|
+
return { apiKey: existingKey };
|
|
32
|
+
}
|
|
33
|
+
// 3. --guest flag
|
|
34
|
+
if (opts.guest) {
|
|
35
|
+
return await doGuestFlow(label);
|
|
36
|
+
}
|
|
37
|
+
// 4. Interactive fallback
|
|
38
|
+
if (interactive) {
|
|
39
|
+
const choice = await promptKeyChoice();
|
|
40
|
+
if (choice === 'paid') {
|
|
41
|
+
const apiKey = await promptApiKey();
|
|
42
|
+
setApiKey(apiKey);
|
|
43
|
+
console.log(`[${label}] ✓ API key saved\n`);
|
|
44
|
+
return { apiKey };
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
return await doGuestFlow(label);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 5. Non-interactive with no key
|
|
51
|
+
console.error(`\n❌ No API key found. Provide --api-key <key> or --guest.`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
async function doGuestFlow(label) {
|
|
55
|
+
console.log(`\n[${label}] Requesting guest session...`);
|
|
56
|
+
try {
|
|
57
|
+
const resp = await requestGuestKey();
|
|
58
|
+
setGuestKey(resp.api_key);
|
|
59
|
+
console.log(`[${label}] ✓ Guest session started\n`);
|
|
60
|
+
const result = { apiKey: resp.api_key };
|
|
61
|
+
// Guest endpoint may return a project_id
|
|
62
|
+
if (resp.project_id) {
|
|
63
|
+
result.projectId = resp.project_id;
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error(`\n❌ Failed to get guest key: ${error.message}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve project from flags, local config, or interactive prompts.
|
|
74
|
+
* Can create new projects when --project-name is given.
|
|
75
|
+
*/
|
|
76
|
+
export async function resolveProject(opts) {
|
|
77
|
+
const label = opts.commandLabel || 'Shift';
|
|
78
|
+
const interactive = opts.interactive ?? true;
|
|
79
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
80
|
+
// 1. Check existing local config
|
|
81
|
+
const existingConfig = readProjectConfig(projectRoot);
|
|
82
|
+
if (existingConfig) {
|
|
83
|
+
console.log(`[${label}] ✓ Project: ${existingConfig.project_name} (${existingConfig.project_id})\n`);
|
|
84
|
+
return { projectId: existingConfig.project_id, projectName: existingConfig.project_name };
|
|
85
|
+
}
|
|
86
|
+
// 2. Explicit --project-id
|
|
87
|
+
if (opts.projectId) {
|
|
88
|
+
const name = opts.projectName || 'CLI Project';
|
|
89
|
+
setProject(opts.projectId, name, projectRoot);
|
|
90
|
+
console.log(`[${label}] ✓ Project "${name}" saved (${opts.projectId})\n`);
|
|
91
|
+
return { projectId: opts.projectId, projectName: name };
|
|
92
|
+
}
|
|
93
|
+
// 3. --project-name: try to match existing, else create new
|
|
94
|
+
if (opts.projectName) {
|
|
95
|
+
return await resolveByName(opts, projectRoot, label);
|
|
96
|
+
}
|
|
97
|
+
// 4. Interactive fallback
|
|
98
|
+
if (interactive) {
|
|
99
|
+
return await interactiveProjectSetup(opts.apiKey, opts.template, projectRoot, label);
|
|
100
|
+
}
|
|
101
|
+
// 5. Non-interactive with no project info
|
|
102
|
+
console.error(`\n❌ No project configured. Provide --project-id or --project-name.`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
async function resolveByName(opts, projectRoot, label) {
|
|
106
|
+
const { apiKey, projectName, template } = opts;
|
|
107
|
+
const interactive = opts.interactive ?? true;
|
|
108
|
+
// Try to match existing project by name
|
|
109
|
+
console.log(`[${label}] Looking for project "${projectName}"...`);
|
|
110
|
+
try {
|
|
111
|
+
const projects = await fetchProjects(apiKey);
|
|
112
|
+
const match = projects.find((p) => p.project_name.toLowerCase() === projectName.toLowerCase());
|
|
113
|
+
if (match) {
|
|
114
|
+
setProject(match.project_id, match.project_name, projectRoot);
|
|
115
|
+
console.log(`[${label}] ✓ Found existing project "${match.project_name}" (${match.project_id})\n`);
|
|
116
|
+
return { projectId: match.project_id, projectName: match.project_name };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Can't fetch projects — proceed to create
|
|
121
|
+
}
|
|
122
|
+
// No match — create new project
|
|
123
|
+
console.log(`[${label}] No existing project named "${projectName}". Creating new project...`);
|
|
124
|
+
return await createNewProject(apiKey, projectName, template, interactive, projectRoot, label);
|
|
125
|
+
}
|
|
126
|
+
async function createNewProject(apiKey, name, templateFlag, interactive, projectRoot, label) {
|
|
127
|
+
let templateId = templateFlag;
|
|
128
|
+
if (!templateId) {
|
|
129
|
+
// Fetch templates and let user select
|
|
130
|
+
try {
|
|
131
|
+
const templates = await fetchMigrationTemplates(apiKey);
|
|
132
|
+
if (templates.length > 0) {
|
|
133
|
+
if (interactive) {
|
|
134
|
+
templateId = await promptSelectTemplate(templates);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
// Non-interactive: use first template
|
|
138
|
+
templateId = templates[0].template_id;
|
|
139
|
+
console.log(`[${label}] Using default template: ${templates[0].name}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
console.log(`[${label}] ⚠️ Could not fetch templates, proceeding without one.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const project = await createProject(apiKey, {
|
|
149
|
+
project_name: name,
|
|
150
|
+
migration_template_id: templateId,
|
|
151
|
+
});
|
|
152
|
+
setProject(project.project_id, project.project_name, projectRoot);
|
|
153
|
+
console.log(`[${label}] ✓ Created project "${project.project_name}" (${project.project_id})\n`);
|
|
154
|
+
return { projectId: project.project_id, projectName: project.project_name };
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
console.error(`\n❌ Failed to create project: ${error.message}`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function interactiveProjectSetup(apiKey, templateFlag, projectRoot, label) {
|
|
162
|
+
// Always ask user what they want to do first
|
|
163
|
+
const action = await promptCreateOrSelect();
|
|
164
|
+
if (action === 'select') {
|
|
165
|
+
let projects = [];
|
|
166
|
+
try {
|
|
167
|
+
projects = await fetchProjects(apiKey);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// fetch failed
|
|
171
|
+
}
|
|
172
|
+
if (projects.length > 0) {
|
|
173
|
+
const selected = await promptSelectProject(projects);
|
|
174
|
+
setProject(selected.project_id, selected.project_name, projectRoot);
|
|
175
|
+
console.log(`[${label}] ✓ Project "${selected.project_name}" saved\n`);
|
|
176
|
+
return { projectId: selected.project_id, projectName: selected.project_name };
|
|
177
|
+
}
|
|
178
|
+
console.log(`[${label}] No existing projects found. Let's create one.\n`);
|
|
179
|
+
}
|
|
180
|
+
// Create new project — suggest directory name as default
|
|
181
|
+
const defaultName = path.basename(projectRoot);
|
|
182
|
+
const name = await promptProjectName(defaultName);
|
|
183
|
+
return await createNewProject(apiKey, name, templateFlag, true, projectRoot, label);
|
|
184
|
+
}
|
package/build/utils/prompts.js
CHANGED
|
@@ -67,3 +67,48 @@ export async function promptKeyChoice() {
|
|
|
67
67
|
process.exit(1);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
export async function promptCreateOrSelect() {
|
|
71
|
+
console.log('\nProject setup:');
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(' 1. Select an existing project');
|
|
74
|
+
console.log(' 2. Create a new project');
|
|
75
|
+
console.log('');
|
|
76
|
+
const answer = await prompt('Select an option (1 or 2): ');
|
|
77
|
+
const selection = parseInt(answer, 10);
|
|
78
|
+
if (selection === 1) {
|
|
79
|
+
return 'select';
|
|
80
|
+
}
|
|
81
|
+
else if (selection === 2) {
|
|
82
|
+
return 'create';
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
console.error('Invalid selection.');
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function promptProjectName(defaultName) {
|
|
90
|
+
const hint = defaultName ? ` (${defaultName})` : '';
|
|
91
|
+
const name = await prompt(`Enter project name${hint}: `);
|
|
92
|
+
const result = name || defaultName || '';
|
|
93
|
+
if (!result) {
|
|
94
|
+
console.error('Error: Project name is required.');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
export async function promptSelectTemplate(templates) {
|
|
100
|
+
console.log('\nAvailable migration templates:');
|
|
101
|
+
console.log('-----------------------------');
|
|
102
|
+
templates.forEach((tmpl, index) => {
|
|
103
|
+
const desc = tmpl.description ? ` - ${tmpl.description}` : '';
|
|
104
|
+
console.log(` ${index + 1}. ${tmpl.name}${desc}`);
|
|
105
|
+
});
|
|
106
|
+
console.log('');
|
|
107
|
+
const answer = await prompt(`Select a template (1-${templates.length}): `);
|
|
108
|
+
const selection = parseInt(answer, 10);
|
|
109
|
+
if (isNaN(selection) || selection < 1 || selection > templates.length) {
|
|
110
|
+
console.error('Invalid selection.');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
return templates[selection - 1].template_id;
|
|
114
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import ignore from 'ignore';
|
|
4
|
+
const SHIFTIGNORE_FILE = '.shiftignore';
|
|
5
|
+
/**
|
|
6
|
+
* Load and parse a .shiftignore file from the project root.
|
|
7
|
+
* Returns an `ignore` instance that can test paths, or null if no .shiftignore exists.
|
|
8
|
+
*
|
|
9
|
+
* .shiftignore uses the same syntax as .gitignore:
|
|
10
|
+
* - Blank lines are ignored
|
|
11
|
+
* - Lines starting with # are comments
|
|
12
|
+
* - Standard glob patterns (*, **, ?)
|
|
13
|
+
* - Trailing / matches directories only
|
|
14
|
+
* - Leading / anchors to root
|
|
15
|
+
* - ! negates a pattern
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_SHIFTIGNORE = `# .shiftignore — files and directories to exclude from Shift indexing
|
|
18
|
+
# Uses the same syntax as .gitignore
|
|
19
|
+
|
|
20
|
+
# Dependencies
|
|
21
|
+
node_modules/
|
|
22
|
+
vendor/
|
|
23
|
+
bower_components/
|
|
24
|
+
|
|
25
|
+
# Build output
|
|
26
|
+
dist/
|
|
27
|
+
build/
|
|
28
|
+
out/
|
|
29
|
+
.next/
|
|
30
|
+
|
|
31
|
+
# Test & coverage
|
|
32
|
+
coverage/
|
|
33
|
+
.nyc_output/
|
|
34
|
+
|
|
35
|
+
# Environment & secrets
|
|
36
|
+
.env
|
|
37
|
+
.env.*
|
|
38
|
+
*.pem
|
|
39
|
+
*.key
|
|
40
|
+
|
|
41
|
+
# Logs
|
|
42
|
+
*.log
|
|
43
|
+
logs/
|
|
44
|
+
|
|
45
|
+
# OS files
|
|
46
|
+
.DS_Store
|
|
47
|
+
Thumbs.db
|
|
48
|
+
|
|
49
|
+
# IDE
|
|
50
|
+
.vscode/
|
|
51
|
+
.idea/
|
|
52
|
+
*.swp
|
|
53
|
+
*.swo
|
|
54
|
+
|
|
55
|
+
# Python
|
|
56
|
+
__pycache__/
|
|
57
|
+
*.pyc
|
|
58
|
+
venv/
|
|
59
|
+
.venv/
|
|
60
|
+
|
|
61
|
+
# Misc
|
|
62
|
+
*.min.js
|
|
63
|
+
*.min.css
|
|
64
|
+
*.map
|
|
65
|
+
`;
|
|
66
|
+
/**
|
|
67
|
+
* Create a default .shiftignore file in the project root if one doesn't exist.
|
|
68
|
+
* Returns true if a new file was created, false if it already exists.
|
|
69
|
+
*/
|
|
70
|
+
export function scaffoldShiftIgnore(projectRoot) {
|
|
71
|
+
const ignorePath = path.join(projectRoot, SHIFTIGNORE_FILE);
|
|
72
|
+
if (fs.existsSync(ignorePath)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
fs.writeFileSync(ignorePath, DEFAULT_SHIFTIGNORE, 'utf-8');
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
console.warn(`[ShiftIgnore] Warning: Could not create ${SHIFTIGNORE_FILE}: ${err.message}`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function loadShiftIgnore(projectRoot) {
|
|
85
|
+
const ignorePath = path.join(projectRoot, SHIFTIGNORE_FILE);
|
|
86
|
+
if (!fs.existsSync(ignorePath)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const content = fs.readFileSync(ignorePath, 'utf-8');
|
|
91
|
+
const ig = ignore();
|
|
92
|
+
ig.add(content);
|
|
93
|
+
return ig;
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.warn(`[ShiftIgnore] Warning: Could not read ${SHIFTIGNORE_FILE}: ${err.message}`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Filter an array of file paths through the shiftIgnore rules.
|
|
102
|
+
* Returns only paths that are NOT ignored.
|
|
103
|
+
*/
|
|
104
|
+
export function filterPaths(ig, paths) {
|
|
105
|
+
if (!ig)
|
|
106
|
+
return paths;
|
|
107
|
+
return paths.filter(p => !ig.ignores(p.replace(/\\/g, '/')));
|
|
108
|
+
}
|
|
@@ -21,7 +21,7 @@ const DEFAULT_EXCLUDE_PATTERNS = [
|
|
|
21
21
|
* Get project tree - matching extension's getProjectTree function
|
|
22
22
|
*/
|
|
23
23
|
export function getProjectTree(workspaceRoot, options = {}) {
|
|
24
|
-
const { depth = 0, exclude_patterns = DEFAULT_EXCLUDE_PATTERNS, } = options;
|
|
24
|
+
const { depth = 0, exclude_patterns = DEFAULT_EXCLUDE_PATTERNS, shiftIgnore = null, } = options;
|
|
25
25
|
let file_count = 0;
|
|
26
26
|
let dir_count = 0;
|
|
27
27
|
let total_size = 0;
|
|
@@ -34,12 +34,21 @@ export function getProjectTree(workspaceRoot, options = {}) {
|
|
|
34
34
|
try {
|
|
35
35
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
36
36
|
for (const entry of entries) {
|
|
37
|
-
// Check if should be excluded
|
|
37
|
+
// Check if should be excluded by built-in patterns
|
|
38
38
|
if (exclude_patterns.some(pattern => entry.name.includes(pattern))) {
|
|
39
39
|
continue;
|
|
40
40
|
}
|
|
41
41
|
const itemPath = path.join(dirPath, entry.name);
|
|
42
42
|
const itemRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
43
|
+
// Check if should be excluded by .shiftignore
|
|
44
|
+
if (shiftIgnore) {
|
|
45
|
+
// Use forward slashes for ignore matching; add trailing / for directories
|
|
46
|
+
const testPath = itemRelativePath.replace(/\\/g, '/');
|
|
47
|
+
const isDir = entry.isDirectory();
|
|
48
|
+
if (shiftIgnore.ignores(isDir ? testPath + '/' : testPath)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
43
52
|
if (entry.isDirectory()) {
|
|
44
53
|
dir_count++;
|
|
45
54
|
const children = scanDirectory(itemPath, currentDepth + 1, itemRelativePath);
|