viepilot 2.49.0 → 2.50.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/CHANGELOG.md +30 -0
- package/lib/intake/adapters/excel-m365.cjs +60 -23
- package/lib/intake/channels.cjs +44 -3
- package/lib/intake/setup-wizard.cjs +215 -0
- package/package.json +1 -1
- package/skills/vp-intake/SKILL.md +43 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.50.1] - 2026-05-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **BUG** SharePoint sharing link download: forward FedAuth session cookies from WOPI page
|
|
14
|
+
redirect chain to the `download.aspx` download request — without these cookies the download
|
|
15
|
+
returned HTTP 401 or an HTML error page even though incognito browser worked fine.
|
|
16
|
+
`httpsGetRaw` now accumulates `Set-Cookie` headers across all hops; `downloadXlsx` retries
|
|
17
|
+
with cookies when the initial response is not a valid xlsx binary.
|
|
18
|
+
|
|
19
|
+
## [2.50.0] - 2026-05-17
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **ENH-084** `vp-intake` Setup Wizard — AUQ-driven channel configuration (Phase 125):
|
|
23
|
+
- **Auto-trigger**: wizard runs automatically when `channels.json` has no real channels
|
|
24
|
+
(only scaffold stubs with `-example` IDs or `REPLACE_WITH_*` values)
|
|
25
|
+
- **`--setup` / `--config` flags**: force wizard even when channels already exist
|
|
26
|
+
- **4-type wizard** via `lib/intake/setup-wizard.cjs`: CSV/TSV, Google Sheets, Excel M365
|
|
27
|
+
(Graph API), SharePoint Sharing Link — collects all required fields via AUQ + free-text
|
|
28
|
+
- **Credentials guide**: for Google Sheets and M365, wizard prints step-by-step setup
|
|
29
|
+
instructions before asking for config values (no live validation)
|
|
30
|
+
- **Preview + confirm**: shows JSON preview of channel config before writing
|
|
31
|
+
- **`appendChannel()`**: strips scaffold stubs, preserves non-stub channels, appends new
|
|
32
|
+
- **`isStubChannel()` / `hasRealChannels()`**: stub detection helpers in `channels.cjs`
|
|
33
|
+
- **Auto-select after wizard**: if exactly 1 channel created → skips channel selection AUQ
|
|
34
|
+
- **`validateChannel` fix**: accepts `sharing_url` as valid alternative to `workbook_id`
|
|
35
|
+
for `excel_m365` channels (ENH-083 compatibility)
|
|
36
|
+
- **18 new tests** (1915 total): stub detection (5), hasRealChannels (5), appendChannel (2),
|
|
37
|
+
slugify (3), extractSpreadsheetId (3)
|
|
38
|
+
- `vp-intake` skill version bumped to **v1.1.0**
|
|
39
|
+
|
|
10
40
|
## [2.49.0] - 2026-05-17
|
|
11
41
|
|
|
12
42
|
### Added
|
|
@@ -79,35 +79,55 @@ function httpsGet(url, headers) {
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Follow redirects and return {status, headers, body, cookies}.
|
|
84
|
+
* Cookies (Set-Cookie) are accumulated across all redirects and forwarded
|
|
85
|
+
* on each hop — required for SharePoint FedAuth session cookies.
|
|
86
|
+
*/
|
|
87
|
+
function httpsGetRaw(url, headers = {}, maxRedirects = 8) {
|
|
84
88
|
return new Promise((resolve, reject) => {
|
|
85
|
-
function doGet(currentUrl, remaining) {
|
|
89
|
+
function doGet(currentUrl, remaining, accCookies) {
|
|
86
90
|
const urlObj = new URL(currentUrl);
|
|
91
|
+
const reqHeaders = {
|
|
92
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
93
|
+
...headers,
|
|
94
|
+
};
|
|
95
|
+
if (accCookies) reqHeaders['Cookie'] = accCookies;
|
|
96
|
+
|
|
87
97
|
const req = https.request({
|
|
88
98
|
hostname: urlObj.hostname,
|
|
89
99
|
path: urlObj.pathname + urlObj.search,
|
|
90
100
|
method: 'GET',
|
|
91
|
-
headers:
|
|
92
|
-
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
93
|
-
...headers,
|
|
94
|
-
},
|
|
101
|
+
headers: reqHeaders,
|
|
95
102
|
}, (res) => {
|
|
103
|
+
// Accumulate Set-Cookie headers across redirects
|
|
104
|
+
const setCookies = (res.headers['set-cookie'] || []).map((c) => c.split(';')[0]);
|
|
105
|
+
const newCookies = setCookies.length
|
|
106
|
+
? [accCookies, ...setCookies].filter(Boolean).join('; ')
|
|
107
|
+
: accCookies;
|
|
108
|
+
|
|
96
109
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
|
|
97
110
|
let nextUrl = res.headers.location;
|
|
98
111
|
if (!nextUrl.startsWith('http')) {
|
|
99
112
|
nextUrl = `${urlObj.protocol}//${urlObj.host}${nextUrl}`;
|
|
100
113
|
}
|
|
101
|
-
|
|
114
|
+
// Drain response body before following redirect
|
|
115
|
+
res.resume();
|
|
116
|
+
return doGet(nextUrl, remaining - 1, newCookies);
|
|
102
117
|
}
|
|
103
118
|
const chunks = [];
|
|
104
119
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
105
|
-
res.on('end', () => resolve({
|
|
120
|
+
res.on('end', () => resolve({
|
|
121
|
+
status: res.statusCode,
|
|
122
|
+
headers: res.headers,
|
|
123
|
+
body: Buffer.concat(chunks),
|
|
124
|
+
cookies: newCookies,
|
|
125
|
+
}));
|
|
106
126
|
});
|
|
107
127
|
req.on('error', reject);
|
|
108
128
|
req.end();
|
|
109
129
|
}
|
|
110
|
-
doGet(url, maxRedirects);
|
|
130
|
+
doGet(url, maxRedirects, '');
|
|
111
131
|
});
|
|
112
132
|
}
|
|
113
133
|
|
|
@@ -177,12 +197,13 @@ function isSharingLink(url) {
|
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
/**
|
|
180
|
-
* Resolve a SharePoint anonymous sharing link to
|
|
200
|
+
* Resolve a SharePoint anonymous sharing link to {downloadUrl, cookies}.
|
|
181
201
|
* Technique: load WOPI viewer page → scrape FileGetUrl (temp auth token embedded in HTML).
|
|
202
|
+
* Returns the cookies collected during the redirect chain — they must be forwarded
|
|
203
|
+
* when downloading (SharePoint FedAuth cookie is required for download.aspx).
|
|
182
204
|
*/
|
|
183
205
|
async function resolveSharePointDownloadUrl(sharingUrl) {
|
|
184
|
-
|
|
185
|
-
const { status, body } = await httpsGetRaw(sharingUrl);
|
|
206
|
+
const { status, body, cookies } = await httpsGetRaw(sharingUrl);
|
|
186
207
|
if (status !== 200) {
|
|
187
208
|
throw new Error(`SharePoint sharing link returned HTTP ${status}. The link may be expired or restricted.`);
|
|
188
209
|
}
|
|
@@ -195,19 +216,35 @@ async function resolveSharePointDownloadUrl(sharingUrl) {
|
|
|
195
216
|
throw new Error('Could not extract download URL from SharePoint viewer page. The file may require sign-in.');
|
|
196
217
|
}
|
|
197
218
|
|
|
198
|
-
// Unescape JSON unicode escapes (
|
|
199
|
-
|
|
219
|
+
// Unescape JSON unicode escapes (& → &)
|
|
220
|
+
const downloadUrl = match[1].replace(/\\u([\da-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
221
|
+
return { downloadUrl, cookies };
|
|
200
222
|
}
|
|
201
223
|
|
|
202
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Download xlsx bytes.
|
|
226
|
+
* cookies: FedAuth cookies from WOPI page — required for download.aspx redirect chain.
|
|
227
|
+
*/
|
|
228
|
+
async function downloadXlsx(downloadUrl, cookies) {
|
|
229
|
+
const extraHeaders = { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*' };
|
|
230
|
+
const { status, body } = await httpsGetRaw(downloadUrl, extraHeaders, 8);
|
|
207
231
|
if (status !== 200) {
|
|
232
|
+
// Retry with cookies if initial attempt fails (some SharePoint tenants need FedAuth)
|
|
233
|
+
if (cookies) {
|
|
234
|
+
const retry = await httpsGetRaw(downloadUrl, { ...extraHeaders, Cookie: cookies }, 8);
|
|
235
|
+
if (retry.status === 200 && retry.body.length > 100) return retry.body;
|
|
236
|
+
}
|
|
208
237
|
throw new Error(`Failed to download xlsx: HTTP ${status}`);
|
|
209
238
|
}
|
|
210
|
-
|
|
239
|
+
// If we got HTML instead of binary (redirect page), retry with cookies
|
|
240
|
+
const magic = body.slice(0, 4).toString('hex');
|
|
241
|
+
if (magic !== '504b0304' && cookies) {
|
|
242
|
+
const retry = await httpsGetRaw(downloadUrl, { ...extraHeaders, Cookie: cookies }, 8);
|
|
243
|
+
if (retry.status === 200 && retry.body.slice(0, 4).toString('hex') === '504b0304') {
|
|
244
|
+
return retry.body;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return body;
|
|
211
248
|
}
|
|
212
249
|
|
|
213
250
|
// ─── xlsx parser (SheetJS) ────────────────────────────────────────────────────
|
|
@@ -249,8 +286,8 @@ function parseXlsxBuffer(buffer, sheetName) {
|
|
|
249
286
|
|
|
250
287
|
async function readViaSharingLink(channel) {
|
|
251
288
|
const sharingUrl = channel.sharing_url;
|
|
252
|
-
const downloadUrl = await resolveSharePointDownloadUrl(sharingUrl);
|
|
253
|
-
const xlsxBuffer = await downloadXlsx(downloadUrl);
|
|
289
|
+
const { downloadUrl, cookies } = await resolveSharePointDownloadUrl(sharingUrl);
|
|
290
|
+
const xlsxBuffer = await downloadXlsx(downloadUrl, cookies);
|
|
254
291
|
const rows = parseXlsxBuffer(xlsxBuffer, channel.sheet_name || null);
|
|
255
292
|
|
|
256
293
|
if (!rows || rows.length === 0) return [];
|
package/lib/intake/channels.cjs
CHANGED
|
@@ -93,8 +93,8 @@ function validateChannel(channel) {
|
|
|
93
93
|
if (channel.type === 'google_sheets' && !channel.spreadsheet_id) {
|
|
94
94
|
throw new Error(`Google Sheets channel "${channel.id}" must have a "spreadsheet_id" field`);
|
|
95
95
|
}
|
|
96
|
-
if (channel.type === 'excel_m365' && !channel.workbook_id) {
|
|
97
|
-
throw new Error(`Excel/M365 channel "${channel.id}" must have
|
|
96
|
+
if (channel.type === 'excel_m365' && !channel.workbook_id && !channel.sharing_url) {
|
|
97
|
+
throw new Error(`Excel/M365 channel "${channel.id}" must have "workbook_id" or "sharing_url"`);
|
|
98
98
|
}
|
|
99
99
|
return true;
|
|
100
100
|
}
|
|
@@ -136,4 +136,45 @@ function initIntakeDir(projectRoot) {
|
|
|
136
136
|
return { intakeDir, channelsPath, credentialsDir };
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
/** Returns true if the channel is an unfilled scaffold stub. */
|
|
140
|
+
function isStubChannel(ch) {
|
|
141
|
+
if (!ch || !ch.id) return true;
|
|
142
|
+
if (ch.id.endsWith('-example')) return true;
|
|
143
|
+
return JSON.stringify(ch).includes('REPLACE_WITH_');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Returns true if channels array has at least one non-stub channel. */
|
|
147
|
+
function hasRealChannels(channels) {
|
|
148
|
+
return Array.isArray(channels) && channels.some((ch) => !isStubChannel(ch));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Append a new channel to channels.json.
|
|
153
|
+
* Strips stub channels; preserves existing non-stub channels.
|
|
154
|
+
*/
|
|
155
|
+
function appendChannel(projectRoot, channelObj) {
|
|
156
|
+
initIntakeDir(projectRoot);
|
|
157
|
+
const channelsPath = path.join(projectRoot, CHANNELS_REL);
|
|
158
|
+
let existing = [];
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(fs.readFileSync(channelsPath, 'utf8'));
|
|
161
|
+
existing = Array.isArray(parsed.channels) ? parsed.channels : [];
|
|
162
|
+
} catch {
|
|
163
|
+
existing = [];
|
|
164
|
+
}
|
|
165
|
+
const nonStubs = existing.filter((ch) => !isStubChannel(ch));
|
|
166
|
+
const updated = [...nonStubs, channelObj];
|
|
167
|
+
fs.writeFileSync(channelsPath, JSON.stringify({ channels: updated }, null, 2) + '\n', 'utf8');
|
|
168
|
+
return channelObj;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
loadChannels,
|
|
173
|
+
validateChannel,
|
|
174
|
+
getChannelById,
|
|
175
|
+
initIntakeDir,
|
|
176
|
+
CHANNELS_SCAFFOLD,
|
|
177
|
+
isStubChannel,
|
|
178
|
+
hasRealChannels,
|
|
179
|
+
appendChannel,
|
|
180
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { appendChannel, initIntakeDir } = require('./channels.cjs');
|
|
5
|
+
const { isSharingLink } = require('./adapters/excel-m365.cjs');
|
|
6
|
+
|
|
7
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function slugify(name) {
|
|
10
|
+
return name
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function extractSpreadsheetId(urlOrId) {
|
|
17
|
+
const match = urlOrId.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
|
|
18
|
+
return match ? match[1] : urlOrId.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function printCredentialsGuide(type) {
|
|
22
|
+
if (type === 'google_sheets') {
|
|
23
|
+
return [
|
|
24
|
+
'',
|
|
25
|
+
' Google Sheets — Service Account setup:',
|
|
26
|
+
' 1. Go to console.cloud.google.com → IAM & Admin → Service Accounts → Create',
|
|
27
|
+
' 2. Create a key (JSON) → download it',
|
|
28
|
+
' 3. Share your Google Sheet with the service account email',
|
|
29
|
+
' 4. Save the JSON key to: .viepilot/.credentials/google-service-account.json',
|
|
30
|
+
'',
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
if (type === 'excel_m365') {
|
|
34
|
+
return [
|
|
35
|
+
'',
|
|
36
|
+
' Excel M365 — Azure App Registration:',
|
|
37
|
+
' 1. Go to portal.azure.com → Azure Active Directory → App Registrations → New',
|
|
38
|
+
' 2. Add API permission: Microsoft Graph → Files.Read.All (Application)',
|
|
39
|
+
' 3. Grant admin consent → Certificates & Secrets → New client secret',
|
|
40
|
+
' 4. Save to: .viepilot/.credentials/m365-credentials.json',
|
|
41
|
+
' { "tenant_id": "...", "client_id": "...", "client_secret": "..." }',
|
|
42
|
+
'',
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Per-type config collectors ───────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
async function collectCsvConfig(name, id, askFn) {
|
|
51
|
+
const filePath = await askFn('Đường dẫn file CSV/TSV? (relative to project root, vd: ./reports/tickets.csv)', null, false);
|
|
52
|
+
const titleCol = await askFn('Header tên cột TITLE là gì? (vd: summary)', null, false);
|
|
53
|
+
const descCol = await askFn('Header tên cột DESCRIPTION là gì? (vd: details)', null, false);
|
|
54
|
+
const repCol = await askFn('Header tên cột REPORTER? (để trống nếu không có)', null, false);
|
|
55
|
+
const dateCol = await askFn('Header tên cột DATE? (để trống nếu không có)', null, false);
|
|
56
|
+
|
|
57
|
+
const column_map = { title: titleCol, description: descCol };
|
|
58
|
+
if (repCol) column_map.reporter = repCol;
|
|
59
|
+
if (dateCol) column_map.date = dateCol;
|
|
60
|
+
|
|
61
|
+
return { id, type: 'csv', name, path: filePath, column_map };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function collectGSheetsConfig(name, id, askFn) {
|
|
65
|
+
const rawId = await askFn('Spreadsheet ID hoặc URL Google Sheets?', null, false);
|
|
66
|
+
const spreadId = extractSpreadsheetId(rawId);
|
|
67
|
+
const sheetName = await askFn('Sheet name? (mặc định: Sheet1 — để trống dùng Sheet1)', null, false) || 'Sheet1';
|
|
68
|
+
const titleCol = await askFn('Cột TITLE ở cột letter nào? (vd: B)', null, false);
|
|
69
|
+
const descCol = await askFn('Cột DESCRIPTION? (vd: C)', null, false);
|
|
70
|
+
const repCol = await askFn('Cột REPORTER? (optional, để trống nếu không có)', null, false);
|
|
71
|
+
const dateCol = await askFn('Cột DATE? (optional)', null, false);
|
|
72
|
+
|
|
73
|
+
const column_map = { title: titleCol.toUpperCase(), description: descCol.toUpperCase() };
|
|
74
|
+
if (repCol) column_map.reporter = repCol.toUpperCase();
|
|
75
|
+
if (dateCol) column_map.date = dateCol.toUpperCase();
|
|
76
|
+
|
|
77
|
+
return { id, type: 'google_sheets', name, spreadsheet_id: spreadId, sheet_name: sheetName, column_map };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function collectM365Config(name, id, askFn) {
|
|
81
|
+
const workbookId = await askFn('Workbook ID? (từ SharePoint URL, dạng item ID)', null, false);
|
|
82
|
+
const sheetName = await askFn('Sheet name? (mặc định: Sheet1)', null, false) || 'Sheet1';
|
|
83
|
+
const titleCol = await askFn('Cột TITLE? (letter, vd: B)', null, false);
|
|
84
|
+
const descCol = await askFn('Cột DESCRIPTION? (vd: C)', null, false);
|
|
85
|
+
const repCol = await askFn('Cột REPORTER? (optional)', null, false);
|
|
86
|
+
const dateCol = await askFn('Cột DATE? (optional)', null, false);
|
|
87
|
+
|
|
88
|
+
const column_map = { title: titleCol.toUpperCase(), description: descCol.toUpperCase() };
|
|
89
|
+
if (repCol) column_map.reporter = repCol.toUpperCase();
|
|
90
|
+
if (dateCol) column_map.date = dateCol.toUpperCase();
|
|
91
|
+
|
|
92
|
+
return { id, type: 'excel_m365', name, workbook_id: workbookId, sheet_name: sheetName, column_map };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function collectSharePointConfig(name, id, askFn) {
|
|
96
|
+
let sharingUrl = '';
|
|
97
|
+
while (!isSharingLink(sharingUrl)) {
|
|
98
|
+
sharingUrl = await askFn(
|
|
99
|
+
'SharePoint sharing URL? (dạng https://xxx.sharepoint.com/:x:/g/... hoặc /:x:/r/...)',
|
|
100
|
+
null,
|
|
101
|
+
false
|
|
102
|
+
);
|
|
103
|
+
if (!isSharingLink(sharingUrl)) {
|
|
104
|
+
process.stdout.write(' ⚠ URL không đúng định dạng sharing link. Vui lòng dán đúng URL chia sẻ.\n');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sheetName = await askFn('Sheet name? (để trống = dùng sheet đầu tiên)', null, false) || '';
|
|
109
|
+
const titleCol = await askFn('Cột TITLE? (letter, vd: B)', null, false);
|
|
110
|
+
const descCol = await askFn('Cột DESCRIPTION? (vd: C)', null, false);
|
|
111
|
+
const repCol = await askFn('Cột REPORTER? (optional)', null, false);
|
|
112
|
+
const dateCol = await askFn('Cột DATE? (optional)', null, false);
|
|
113
|
+
|
|
114
|
+
const column_map = { title: titleCol.toUpperCase(), description: descCol.toUpperCase() };
|
|
115
|
+
if (repCol) column_map.reporter = repCol.toUpperCase();
|
|
116
|
+
if (dateCol) column_map.date = dateCol.toUpperCase();
|
|
117
|
+
|
|
118
|
+
const ch = { id, type: 'excel_m365', name, sharing_url: sharingUrl, column_map };
|
|
119
|
+
if (sheetName) ch.sheet_name = sheetName;
|
|
120
|
+
return ch;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Single channel wizard ────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
const CHANNEL_TYPE_OPTIONS = [
|
|
126
|
+
{ label: 'CSV / TSV file (local)', description: 'Đọc từ file .csv hoặc .tsv trên máy' },
|
|
127
|
+
{ label: 'Google Sheets', description: 'Đọc từ Google Sheets qua Service Account' },
|
|
128
|
+
{ label: 'Excel M365 — Graph API (Azure App)', description: 'Đọc từ OneDrive/SharePoint qua Microsoft Graph API' },
|
|
129
|
+
{ label: 'SharePoint Sharing Link (no credentials)', description: 'Dán link chia sẻ SharePoint — không cần cấu hình OAuth' },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
async function wizardOneChannel(projectRoot, askFn) {
|
|
133
|
+
// Step 1 — loại channel
|
|
134
|
+
const typeChoice = await askFn(
|
|
135
|
+
'Bạn muốn cấu hình loại nguồn ticket nào?',
|
|
136
|
+
CHANNEL_TYPE_OPTIONS,
|
|
137
|
+
false
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Step 2 — display name → id
|
|
141
|
+
const name = await askFn('Tên hiển thị cho channel này? (vd: UAT Tickets Q2)', null, false);
|
|
142
|
+
const id = slugify(name) || `channel-${Date.now()}`;
|
|
143
|
+
|
|
144
|
+
// Step 3 — collect per-type fields
|
|
145
|
+
let channelObj;
|
|
146
|
+
if (typeChoice === 'CSV / TSV file (local)') {
|
|
147
|
+
channelObj = await collectCsvConfig(name, id, askFn);
|
|
148
|
+
} else if (typeChoice === 'Google Sheets') {
|
|
149
|
+
process.stdout.write(printCredentialsGuide('google_sheets'));
|
|
150
|
+
channelObj = await collectGSheetsConfig(name, id, askFn);
|
|
151
|
+
} else if (typeChoice === 'Excel M365 — Graph API (Azure App)') {
|
|
152
|
+
process.stdout.write(printCredentialsGuide('excel_m365'));
|
|
153
|
+
channelObj = await collectM365Config(name, id, askFn);
|
|
154
|
+
} else {
|
|
155
|
+
// SharePoint Sharing Link
|
|
156
|
+
channelObj = await collectSharePointConfig(name, id, askFn);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Step 4 — preview + confirm
|
|
160
|
+
process.stdout.write('\n Preview cấu hình:\n');
|
|
161
|
+
process.stdout.write(JSON.stringify(channelObj, null, 2).split('\n').map(l => ' ' + l).join('\n'));
|
|
162
|
+
process.stdout.write('\n');
|
|
163
|
+
|
|
164
|
+
const confirm = await askFn('Lưu cấu hình này?', [
|
|
165
|
+
{ label: 'Lưu', description: 'Ghi channel vào channels.json' },
|
|
166
|
+
{ label: 'Cấu hình lại', description: 'Nhập lại thông tin channel này' },
|
|
167
|
+
{ label: 'Huỷ', description: 'Bỏ qua channel này' },
|
|
168
|
+
], false);
|
|
169
|
+
|
|
170
|
+
if (confirm === 'Cấu hình lại') {
|
|
171
|
+
return wizardOneChannel(projectRoot, askFn);
|
|
172
|
+
}
|
|
173
|
+
if (confirm === 'Huỷ') {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Step 5 — write
|
|
178
|
+
appendChannel(projectRoot, channelObj);
|
|
179
|
+
process.stdout.write(`\n ✓ Channel "${name}" đã được lưu vào .viepilot/intake/channels.json\n\n`);
|
|
180
|
+
return channelObj;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Public entry point ───────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Run the interactive setup wizard.
|
|
187
|
+
* @param {string} projectRoot
|
|
188
|
+
* @param {Function} askFn async (question, options, multiSelect) → string
|
|
189
|
+
* @returns {object|null} last channel created, or null if none
|
|
190
|
+
*/
|
|
191
|
+
async function runSetupWizard(projectRoot, askFn) {
|
|
192
|
+
initIntakeDir(projectRoot);
|
|
193
|
+
process.stdout.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
194
|
+
process.stdout.write(' VP-INTAKE ► Setup Wizard\n');
|
|
195
|
+
process.stdout.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n');
|
|
196
|
+
|
|
197
|
+
let lastCreated = null;
|
|
198
|
+
|
|
199
|
+
// eslint-disable-next-line no-constant-condition
|
|
200
|
+
while (true) {
|
|
201
|
+
const ch = await wizardOneChannel(projectRoot, askFn);
|
|
202
|
+
if (ch) lastCreated = ch;
|
|
203
|
+
|
|
204
|
+
const more = await askFn('Cấu hình thêm channel khác?', [
|
|
205
|
+
{ label: 'Không — tiếp tục import', description: 'Chuyển sang chọn channel và import ticket' },
|
|
206
|
+
{ label: 'Có — thêm channel', description: 'Cấu hình thêm một nguồn ticket nữa' },
|
|
207
|
+
], false);
|
|
208
|
+
|
|
209
|
+
if (more !== 'Có — thêm channel') break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return lastCreated;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { runSetupWizard, slugify, extractSpreadsheetId };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: vp-intake
|
|
3
3
|
description: "Import and triage tickets from Excel/M365 Online, Google Sheets, or CSV/TSV files — classify as BUG/ENH, accept/decline via AskUserQuestion, write back to source, generate TRIAGE report"
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
<greeting>
|
|
@@ -11,7 +11,7 @@ Output this banner as the **first** thing on every invocation — before questio
|
|
|
11
11
|
|
|
12
12
|
```
|
|
13
13
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
14
|
-
VIEPILOT ► VP-INTAKE v1.
|
|
14
|
+
VIEPILOT ► VP-INTAKE v1.1.0 (fw 2.50.0)
|
|
15
15
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
16
16
|
```
|
|
17
17
|
</greeting>
|
|
@@ -78,13 +78,16 @@ session report.
|
|
|
78
78
|
Optional flags:
|
|
79
79
|
- `--channel <id>` : Skip channel selection, use this channel ID directly
|
|
80
80
|
- `--dry-run` : Classify and show tickets without creating requests or writing back
|
|
81
|
+
- `--setup` : Force setup wizard — configure a new channel now (even if channels already exist)
|
|
82
|
+
- `--config` : Alias for `--setup`
|
|
81
83
|
|
|
82
84
|
**Supported channel types:**
|
|
83
85
|
| Type | Auth | Config field |
|
|
84
86
|
|------|------|-------------|
|
|
85
87
|
| `csv` | None | `path` (local file) |
|
|
86
88
|
| `google_sheets` | Service Account JSON | `spreadsheet_id` + `sheet_name` |
|
|
87
|
-
| `excel_m365` | Azure App Registration | `workbook_id` + `sheet_name` |
|
|
89
|
+
| `excel_m365` (Graph API) | Azure App Registration | `workbook_id` + `sheet_name` |
|
|
90
|
+
| `excel_m365` (sharing link) | None — anonymous | `sharing_url` |
|
|
88
91
|
|
|
89
92
|
**Config file:** `.viepilot/intake/channels.json`
|
|
90
93
|
**Credentials dir:** `.viepilot/.credentials/` (gitignored)
|
|
@@ -92,6 +95,36 @@ Optional flags:
|
|
|
92
95
|
|
|
93
96
|
<process>
|
|
94
97
|
|
|
98
|
+
### Step 0: Setup wizard detection (ENH-084)
|
|
99
|
+
|
|
100
|
+
**Check wizard trigger conditions:**
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
const args = parseArgs(VP_ARGS);
|
|
104
|
+
const forceSetup = args.includes('--setup') || args.includes('--config');
|
|
105
|
+
|
|
106
|
+
// Init dir + load channels
|
|
107
|
+
initIntakeDir(projectRoot); // lib/intake/channels.cjs
|
|
108
|
+
const { channels } = loadChannels(projectRoot);
|
|
109
|
+
const needsSetup = forceSetup || !hasRealChannels(channels); // lib/intake/channels.cjs
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**If `needsSetup` is true:**
|
|
113
|
+
|
|
114
|
+
Call `runSetupWizard(projectRoot, askFn)` from `lib/intake/setup-wizard.cjs`.
|
|
115
|
+
|
|
116
|
+
`askFn` wraps `AskUserQuestion` for structured option prompts, and falls back to free-text
|
|
117
|
+
input for open-ended fields (column names, file paths, URLs).
|
|
118
|
+
|
|
119
|
+
After wizard completes:
|
|
120
|
+
- Reload channels from channels.json
|
|
121
|
+
- If exactly 1 channel was just created → auto-select it and skip Step 3 (go to Step 4)
|
|
122
|
+
- Otherwise → continue to Step 3 (channel select AUQ with all available real channels)
|
|
123
|
+
|
|
124
|
+
**If `needsSetup` is false** and no `--setup` flag → skip Step 0 entirely, continue to Step 1.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
95
128
|
### Step 1: Init intake directory
|
|
96
129
|
|
|
97
130
|
```bash
|
|
@@ -102,13 +135,11 @@ This creates `.viepilot/intake/channels.json` (scaffold) and `.viepilot/.credent
|
|
|
102
135
|
|
|
103
136
|
### Step 2: Load channels
|
|
104
137
|
|
|
105
|
-
Read `.viepilot/intake/channels.json`. If no channels
|
|
106
|
-
tell the user:
|
|
138
|
+
Read `.viepilot/intake/channels.json`. If no real channels exist after Step 0:
|
|
107
139
|
|
|
108
140
|
```
|
|
109
141
|
No channels configured yet.
|
|
110
|
-
|
|
111
|
-
Run vp-tools intake-init to see the config scaffold.
|
|
142
|
+
Run /vp-intake --setup to configure a channel interactively.
|
|
112
143
|
```
|
|
113
144
|
|
|
114
145
|
### Step 3: Select channel (AUQ single-select)
|
|
@@ -188,6 +219,8 @@ options:
|
|
|
188
219
|
</process>
|
|
189
220
|
|
|
190
221
|
<success_criteria>
|
|
222
|
+
- [ ] Setup wizard triggers automatically when no real channels exist (or --setup flag)
|
|
223
|
+
- [ ] Wizard collects channel config via AUQ and writes to channels.json
|
|
191
224
|
- [ ] Channels loaded from `.viepilot/intake/channels.json`
|
|
192
225
|
- [ ] Correct adapter dispatched based on channel type
|
|
193
226
|
- [ ] Tickets classified as BUG / ENH / UNCLEAR
|
|
@@ -211,6 +244,7 @@ options:
|
|
|
211
244
|
| GitHub Copilot | ✅ Text fallback | Via `.agent.md` |
|
|
212
245
|
|
|
213
246
|
**Prompts in this skill:**
|
|
247
|
+
- Setup wizard: channel type, display name, field config, preview+confirm (Step 0)
|
|
214
248
|
- Channel selection (Step 3)
|
|
215
249
|
- Ticket accept/decline multi-select (Step 5)
|
|
216
250
|
- Decline reason (Step 5)
|
|
@@ -218,6 +252,8 @@ options:
|
|
|
218
252
|
- Next action (Step 8)
|
|
219
253
|
|
|
220
254
|
## Capabilities
|
|
255
|
+
- **Setup wizard** (`--setup`): AUQ-driven channel configuration — writes directly to channels.json
|
|
256
|
+
- Read tickets from SharePoint sharing links (anonymous WOPI download, no credentials)
|
|
221
257
|
- Read tickets from Excel/Microsoft 365 Online via Microsoft Graph API
|
|
222
258
|
- Read tickets from Google Sheets via Sheets API v4
|
|
223
259
|
- Read tickets from local CSV/TSV files
|