@surph_ai/cli 0.0.26 → 0.0.27
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/cli.js +18 -99
- package/package.json +2 -1
- package/scaffold/index.js +164 -82
- package/scaffold/tool/package.json +1 -1
- package/utils/browser.js +153 -0
- package/utils/command.js +86 -0
- package/utils/progress.js +226 -0
- package/utils/windowsConsole.js +130 -0
package/cli.js
CHANGED
|
@@ -12,7 +12,6 @@ const fs = require('fs')
|
|
|
12
12
|
const os = require('os')
|
|
13
13
|
const http = require('http')
|
|
14
14
|
const crypto = require('crypto')
|
|
15
|
-
const { exec } = require('child_process')
|
|
16
15
|
const dotenv = require('dotenv')
|
|
17
16
|
const prompt = require('prompt')
|
|
18
17
|
const shell = require('shelljs')
|
|
@@ -23,6 +22,8 @@ const deploy = require('./deploy')
|
|
|
23
22
|
const scaffold = require('./scaffold')
|
|
24
23
|
const { ensureChat, chatNodePath } = require('./toolserver/ensure-chat')
|
|
25
24
|
const utils = require('./utils')
|
|
25
|
+
const { openBrowser, openBrowserWhenReady } = require('./utils/browser')
|
|
26
|
+
const { prepareWindowsConsole } = require('./utils/windowsConsole')
|
|
26
27
|
const packageJson = require('./package.json')
|
|
27
28
|
|
|
28
29
|
const VERSION = packageJson.version
|
|
@@ -68,20 +69,6 @@ const surphSession = async () => {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
// Cross-platform browser opener. Falls back to printing the URL — matters for
|
|
72
|
-
// SSH / headless / broken default handlers, where `open` silently no-ops.
|
|
73
|
-
const openBrowser = (url) => {
|
|
74
|
-
const cmd = process.platform === 'darwin' ? `open "${url}"`
|
|
75
|
-
: process.platform === 'win32' ? `start "" "${url}"`
|
|
76
|
-
: `xdg-open "${url}"`
|
|
77
|
-
exec(cmd, (err) => {
|
|
78
|
-
if (err) {
|
|
79
|
-
console.log(colors.yellow(`\nCouldn't open browser automatically. Open this URL manually:\n`))
|
|
80
|
-
console.log(colors.cyan(url) + '\n')
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
|
|
85
72
|
const loginSuccessHtml = (username) => `<!doctype html>
|
|
86
73
|
<html>
|
|
87
74
|
<head><meta charset="utf-8"><title>Surph — Logged in</title>
|
|
@@ -273,50 +260,6 @@ program.command('profile')
|
|
|
273
260
|
// console.log(text)
|
|
274
261
|
})
|
|
275
262
|
|
|
276
|
-
/*
|
|
277
|
-
program.command('login')
|
|
278
|
-
.description('login to Surph')
|
|
279
|
-
.action(async (cmd, options) => {
|
|
280
|
-
try {
|
|
281
|
-
const credentials = await auth.showPrompt()
|
|
282
|
-
|
|
283
|
-
const resp = await axios({
|
|
284
|
-
url: `${PORTAL_URL}/auth/login`,
|
|
285
|
-
method: 'post',
|
|
286
|
-
data: credentials,
|
|
287
|
-
headers: { Accept: 'application/json'}
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
const { data } = resp.data
|
|
291
|
-
const user = { id: data._id, username: data.username }
|
|
292
|
-
|
|
293
|
-
shell.cd('')
|
|
294
|
-
utils.write('.Surph_user', JSON.stringify(user, null, 2) + '\n')
|
|
295
|
-
|
|
296
|
-
const successText = `${colors.cyan(`\nSuccess! `)} ${colors.brightGreen(`Currently Logged In As ${user.username}\n`)}`
|
|
297
|
-
console.log(successText)
|
|
298
|
-
} catch (error) {
|
|
299
|
-
utils.printError(error)
|
|
300
|
-
}
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
program.command('dev')
|
|
304
|
-
.description('run dev server')
|
|
305
|
-
.action(async (cmd, options) => {
|
|
306
|
-
try {
|
|
307
|
-
// shell.cp('-R', path.join(__dirname, '../default/lambda/js/index.js'), 'vectors/js/index.js')
|
|
308
|
-
shell.cd(path.join(__dirname, 'base/Surph-base'))
|
|
309
|
-
shell.exec('npm run dev')
|
|
310
|
-
// shell.cd('Surph-base')
|
|
311
|
-
|
|
312
|
-
// shell.exec('gulp build --gulpfile node_modules/@turbo360/turbo-sdk/src/gulpfile.js --silent')
|
|
313
|
-
|
|
314
|
-
} catch (error) {
|
|
315
|
-
utils.printError(error)
|
|
316
|
-
}
|
|
317
|
-
})
|
|
318
|
-
*/
|
|
319
|
-
|
|
320
263
|
program.command('new <name>')
|
|
321
264
|
.description('create new Surph app')
|
|
322
265
|
.action(name => scaffold(name, 'project'))
|
|
@@ -404,7 +347,17 @@ program.command('manifest')
|
|
|
404
347
|
|
|
405
348
|
program.command('browser <url>')
|
|
406
349
|
.description('open browser to url')
|
|
407
|
-
.
|
|
350
|
+
.option('--timeout <seconds>', 'how long to wait for a local server before giving up', '60')
|
|
351
|
+
.option('--no-wait', 'open immediately instead of waiting for the port to accept connections')
|
|
352
|
+
.action(async (url, options) => {
|
|
353
|
+
const seconds = Number(options.timeout)
|
|
354
|
+
const timeout = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 60000
|
|
355
|
+
if (options.wait === false) {
|
|
356
|
+
await openBrowser(url)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
await openBrowserWhenReady(url, { timeout })
|
|
360
|
+
})
|
|
408
361
|
|
|
409
362
|
/*
|
|
410
363
|
program.command('connect')
|
|
@@ -578,7 +531,7 @@ program.command('publish')
|
|
|
578
531
|
})
|
|
579
532
|
|
|
580
533
|
const data = await resp.json()
|
|
581
|
-
console.log(
|
|
534
|
+
console.log(`\nSurph tool updated. To view, visit:\n\nhttps://surph.ai/tool/${slug}\n`)
|
|
582
535
|
} catch (error) {
|
|
583
536
|
console.log(colors.red('\nError: '+error.message+'\n'))
|
|
584
537
|
}
|
|
@@ -589,44 +542,6 @@ program.command('deploy')
|
|
|
589
542
|
.description('Deploy to Surph')
|
|
590
543
|
.action(async (cmd, options) => {
|
|
591
544
|
try {
|
|
592
|
-
/*
|
|
593
|
-
const { type } = cmd
|
|
594
|
-
if (type !== 'default' && type !== 'static') {
|
|
595
|
-
const msg = colors.red(`\nError: Invalid deployment type ${type}.\n`) +
|
|
596
|
-
colors.white(`Please select type "static" or omit type flag.\n`);
|
|
597
|
-
program.error(msg, ERRORS.INVALID_PARAMS)
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
const user = await auth.currentUser() // must be logged in.
|
|
601
|
-
if (user === false) {
|
|
602
|
-
program.error(`${colors.cyan('\nNot logged in. To log in:')}\n\n${colors.white('$ vly login')}`,
|
|
603
|
-
ERRORS.NOT_LOGGED_IN);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
*/
|
|
607
|
-
|
|
608
|
-
/*
|
|
609
|
-
const apps = await Realm.restQuery('project', 'get', { slug: TURBO_PROJECT_SLUG })
|
|
610
|
-
if (apps.length === 0) {
|
|
611
|
-
program.error(`App ${TURBO_PROJECT} not found.`, ERRORS.PROJECT_NOT_FOUND)
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
const { members } = apps[0]
|
|
615
|
-
if (!members.find(member => (member.id === user.id))) {
|
|
616
|
-
program.error('Unauthorized', ERRORS.NOT_AUTHORIZED)
|
|
617
|
-
}
|
|
618
|
-
*/
|
|
619
|
-
|
|
620
|
-
/*
|
|
621
|
-
const { SURPH_TOOL = null } = dotenv.parse(utils.readFile('.env') ?? '')
|
|
622
|
-
|
|
623
|
-
if (SURPH_TOOL === null) {
|
|
624
|
-
program.error('Surph project not connected. To connect, run:\n\n$ surph connect', ERRORS.PROJECT_NOT_CONNECTED)
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
await deploy.tool({ SURPH_TOOL_SLUG: SURPH_TOOL })
|
|
628
|
-
*/
|
|
629
|
-
|
|
630
545
|
const manifest = require('../../../manifest.json')
|
|
631
546
|
if (!manifest) {
|
|
632
547
|
throw new Error('manifest.json not found')
|
|
@@ -641,4 +556,8 @@ program.command('deploy')
|
|
|
641
556
|
}
|
|
642
557
|
})
|
|
643
558
|
|
|
559
|
+
// Legacy Windows consoles block stdout while QuickEdit selection is active.
|
|
560
|
+
// See utils/windowsConsole.js (SURPH_NO_CONSOLE_FIX=1 to skip).
|
|
561
|
+
prepareWindowsConsole()
|
|
562
|
+
|
|
644
563
|
program.parse(process.argv)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@surph_ai/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.27",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "./types.d.ts",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"adm-zip": "^0.5.10",
|
|
21
21
|
"bestzip": "^2.2.1",
|
|
22
22
|
"cheerio": "^1.2.0",
|
|
23
|
+
"cli-progress": "^3.12.0",
|
|
23
24
|
"colors": "^1.4.0",
|
|
24
25
|
"commander": "^14.0.1",
|
|
25
26
|
"cookie": "^0.5.0",
|
package/scaffold/index.js
CHANGED
|
@@ -3,21 +3,28 @@ const shell = require('shelljs')
|
|
|
3
3
|
const path = require('path')
|
|
4
4
|
const fs = require('fs')
|
|
5
5
|
const dotenv = require('dotenv')
|
|
6
|
+
const colors = require('colors/safe')
|
|
6
7
|
const utils = require('../utils')
|
|
8
|
+
const { StepProgress } = require('../utils/progress')
|
|
9
|
+
const { runCaptured } = require('../utils/command')
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
11
|
+
const CHEATSHEET_URL = 'https://surph.s3.us-east-1.amazonaws.com/tools/cli/CHEATSHEET.md'
|
|
12
|
+
|
|
13
|
+
// Scaffold steps in order. `weight` is each step's share of overall progress (sum ≈ 100).
|
|
14
|
+
const TOOL_STEPS = [
|
|
15
|
+
{ id: 'directory', doing: 'Creating project directory', done: 'Project directory created', weight: 1 },
|
|
16
|
+
{ id: 'package', doing: 'Writing package.json', done: 'package.json written', weight: 1 },
|
|
17
|
+
{ id: 'examples', doing: 'Copying example tools', done: 'Example tools copied', weight: 2 },
|
|
18
|
+
{ id: 'manifests', doing: 'Writing manifests', done: 'Manifests written', weight: 1 },
|
|
19
|
+
{ id: 'config', doing: 'Writing .env and tsconfig.json', done: '.env and tsconfig.json written', weight: 1 },
|
|
20
|
+
{ id: 'cheatsheet', doing: 'Fetching cheatsheet', done: 'Cheatsheet fetched', weight: 4 },
|
|
21
|
+
{ id: 'install', doing: 'Installing dependencies', done: 'Dependencies installed', weight: 82 },
|
|
22
|
+
{ id: 'devserver', doing: 'Starting dev server', done: 'Dev server starting', weight: 8 }
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
// Expected package count for install sub-progress; grows if the tree is larger.
|
|
26
|
+
// Used as clamp for install sub-progress. Never shown to the user.
|
|
27
|
+
const EXPECTED_PACKAGES = 324
|
|
21
28
|
|
|
22
29
|
function generateRandomSuffix(length = 6) {
|
|
23
30
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
@@ -43,10 +50,6 @@ function slugify(text, randomLength = 8) {
|
|
|
43
50
|
return slug;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
// var PLATFORM_VECTOR_URL = 'https://platform.turbo360-vector.com'
|
|
47
|
-
// var VERTEX_NPM_VERSION = "^0.30.25"
|
|
48
|
-
// var TURBO_NPM_VERSION = "^1.8.69"
|
|
49
|
-
|
|
50
53
|
const nextSteps = function(name){
|
|
51
54
|
console.log('\n- - - - - Next Steps - - - - - ')
|
|
52
55
|
console.log('1. $ cd ' + name)
|
|
@@ -57,28 +60,111 @@ const nextSteps = function(name){
|
|
|
57
60
|
console.log('For full documentation, visit https://docs.turbo360.co\n')
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
const manifestExample = {
|
|
64
|
+
name: 'youtube-transcript',
|
|
65
|
+
slug: 'y2script',
|
|
66
|
+
type: 'custom',
|
|
67
|
+
description: 'Fetch the plain-text transcript (spoken words) of a YouTube video. Use this whenever the user asks about, quotes from, summarizes, or wants to search inside the content of a YouTube video — including questions like "what does this video say about X", "summarize this video", "pull the transcript", or when the user pastes a YouTube URL and expects you to know its contents. Do NOT use this for non-YouTube videos (it will fail on other video sources).',
|
|
68
|
+
parameters: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
video: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description: 'The YouTube video to transcribe. Accepts either a full URL (e.g. "https://www.youtube.com/watch?v=EwusdkKUlwI", "https://youtu.be/EwusdkKUlwI") or the bare 11-character video id (e.g. "EwusdkKUlwI"). Extract this from the user\'s message when they paste a link.'
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
required: ['video']
|
|
77
|
+
}
|
|
78
|
+
}
|
|
60
79
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
const tsConfig = {
|
|
81
|
+
"compilerOptions": {
|
|
82
|
+
"target": "ES2022",
|
|
83
|
+
"module": "Node16",
|
|
84
|
+
"moduleResolution": "Node16",
|
|
85
|
+
"lib": ["ES2022"],
|
|
86
|
+
"types": ["node"],
|
|
87
|
+
"outDir": "./dist",
|
|
88
|
+
"rootDir": ".",
|
|
89
|
+
"strict": true,
|
|
90
|
+
"esModuleInterop": true,
|
|
91
|
+
"skipLibCheck": true,
|
|
92
|
+
"forceConsistentCasingInFileNames": true,
|
|
93
|
+
"resolveJsonModule": true
|
|
94
|
+
},
|
|
95
|
+
"include": ["tools/**/*.ts"],
|
|
96
|
+
"exclude": ["node_modules", "dist"]
|
|
97
|
+
}
|
|
69
98
|
|
|
70
|
-
|
|
99
|
+
// Capture npm output (not streamed). `--loglevel=info` emits per-package http lines for progress.
|
|
100
|
+
const NPM_METADATA = /^npm (?:http fetch GET \d+|http cache) (?:.*@)?https?:\/\/[^/]+\/([^\s]+?)(?:\s|$)/
|
|
101
|
+
const NPM_TARBALL = /\.tgz\b/
|
|
102
|
+
const installDependencies = async (progress, cwd) => {
|
|
103
|
+
const startedAt = Date.now()
|
|
104
|
+
const resolved = new Set()
|
|
105
|
+
const fetched = new Set()
|
|
106
|
+
let sawAnySignal = false
|
|
71
107
|
|
|
72
|
-
|
|
73
|
-
|
|
108
|
+
const read = (line) => {
|
|
109
|
+
const match = NPM_METADATA.exec(line)
|
|
110
|
+
if (match) {
|
|
111
|
+
sawAnySignal = true
|
|
112
|
+
if (NPM_TARBALL.test(line)) fetched.add(match[1])
|
|
113
|
+
else resolved.add(match[1])
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const tick = () => {
|
|
118
|
+
const elapsed = Date.now() - startedAt
|
|
119
|
+
|
|
120
|
+
if (!sawAnySignal) {
|
|
121
|
+
progress.advance(0.9 * (1 - Math.exp(-elapsed / 45000)))
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const denom = Math.max(EXPECTED_PACKAGES, resolved.size, fetched.size)
|
|
126
|
+
|
|
127
|
+
const resolveShare = Math.min(1, resolved.size / denom)
|
|
128
|
+
const fetchShare = Math.min(1, fetched.size / denom)
|
|
129
|
+
const signal = 0.5 * resolveShare + 0.5 * fetchShare
|
|
130
|
+
|
|
131
|
+
const floor = 0.85 * (1 - Math.exp(-elapsed / 60000))
|
|
132
|
+
const fraction = Math.min(0.97, Math.max(signal, floor))
|
|
133
|
+
|
|
134
|
+
progress.advance(fraction)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ticker = setInterval(tick, 250)
|
|
138
|
+
try {
|
|
139
|
+
await runCaptured('npm', ['install', '--no-fund', '--no-audit', '--loglevel=info'], {
|
|
140
|
+
cwd,
|
|
141
|
+
onLine: read,
|
|
142
|
+
retain: (line) => !/^npm (?:http |verbose |silly |timing )/.test(line)
|
|
143
|
+
})
|
|
144
|
+
} finally {
|
|
145
|
+
clearInterval(ticker)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const scaffoldTool = async (name) => {
|
|
150
|
+
const progress = new StepProgress(TOOL_STEPS)
|
|
151
|
+
console.log(progress.paint(colors.cyan, `\nCreating Surph tool: ${name}\n`))
|
|
152
|
+
progress.start()
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
progress.begin('directory')
|
|
74
156
|
shell.mkdir('-p', name)
|
|
75
157
|
shell.cd(name)
|
|
158
|
+
await progress.done()
|
|
76
159
|
|
|
160
|
+
progress.begin('package')
|
|
77
161
|
const pkgJsonStr = utils.readFile(path.join(__dirname, 'tool/package.json'))
|
|
78
162
|
const pkgJson = JSON.parse(pkgJsonStr)
|
|
79
163
|
pkgJson.name = name
|
|
80
164
|
utils.write('package.json', JSON.stringify(pkgJson, null, 2) + '\n')
|
|
165
|
+
await progress.done()
|
|
81
166
|
|
|
167
|
+
progress.begin('examples')
|
|
82
168
|
shell.mkdir('-p', 'tools')
|
|
83
169
|
shell.cp('-R', path.join(__dirname, `tool/tools/index.ts`), 'tools/index.ts')
|
|
84
170
|
|
|
@@ -92,31 +178,12 @@ module.exports = async (name, type = 'app') => {
|
|
|
92
178
|
shell.mkdir('-p', 'examples/weather')
|
|
93
179
|
shell.cp('-R', path.join(__dirname, `tool/examples/weather/index.ts`), 'examples/weather/index.ts')
|
|
94
180
|
shell.cp('-R', path.join(__dirname, `tool/examples/weather/manifest.json`), 'examples/weather/manifest.json')
|
|
181
|
+
await progress.done()
|
|
95
182
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// shell.cp('-R', path.join(__dirname, `tool/examples/dealfinder/manifest.json`), 'examples/dealfinder/manifest.json')
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
utils.write('manifest-example.json', JSON.stringify({
|
|
102
|
-
name: 'youtube-transcript',
|
|
103
|
-
slug: 'y2script',
|
|
104
|
-
type: 'custom',
|
|
105
|
-
description: 'Fetch the plain-text transcript (spoken words) of a YouTube video. Use this whenever the user asks about, quotes from, summarizes, or wants to search inside the content of a YouTube video — including questions like "what does this video say about X", "summarize this video", "pull the transcript", or when the user pastes a YouTube URL and expects you to know its contents. Do NOT use this for non-YouTube videos (it will fail on other video sources).',
|
|
106
|
-
parameters: {
|
|
107
|
-
type: 'object',
|
|
108
|
-
properties: {
|
|
109
|
-
video: {
|
|
110
|
-
type: 'string',
|
|
111
|
-
description: 'The YouTube video to transcribe. Accepts either a full URL (e.g. "https://www.youtube.com/watch?v=EwusdkKUlwI", "https://youtu.be/EwusdkKUlwI") or the bare 11-character video id (e.g. "EwusdkKUlwI"). Extract this from the user\'s message when they paste a link.'
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
required: ['video']
|
|
115
|
-
}
|
|
116
|
-
} , null, 2) + '\n')
|
|
183
|
+
progress.begin('manifests')
|
|
184
|
+
utils.write('manifest-example.json', JSON.stringify(manifestExample, null, 2) + '\n')
|
|
117
185
|
|
|
118
186
|
const slug = slugify(name, 8)
|
|
119
|
-
|
|
120
187
|
const manifest = {}
|
|
121
188
|
manifest[slug] = {
|
|
122
189
|
name,
|
|
@@ -131,46 +198,61 @@ module.exports = async (name, type = 'app') => {
|
|
|
131
198
|
}
|
|
132
199
|
|
|
133
200
|
utils.write('manifest.json', JSON.stringify(manifest, null, 2) + '\n')
|
|
201
|
+
await progress.done()
|
|
134
202
|
|
|
203
|
+
progress.begin('config')
|
|
135
204
|
const envStr = utils.readFile(path.join(__dirname, 'tool/env.txt'))
|
|
136
205
|
utils.write('.env', envStr.replace('<SURPH_TOOL>', slug))
|
|
206
|
+
utils.write('tsconfig.json', JSON.stringify(tsConfig, null, 2) + '\n')
|
|
207
|
+
await progress.done()
|
|
137
208
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
"skipLibCheck": true,
|
|
150
|
-
"forceConsistentCasingInFileNames": true,
|
|
151
|
-
"resolveJsonModule": true
|
|
152
|
-
},
|
|
153
|
-
"include": ["tools/**/*.ts"],
|
|
154
|
-
"exclude": ["node_modules", "dist"]
|
|
209
|
+
progress.begin('cheatsheet')
|
|
210
|
+
try {
|
|
211
|
+
const cheatsheetResp = await fetch(CHEATSHEET_URL)
|
|
212
|
+
if (!cheatsheetResp.ok) {
|
|
213
|
+
throw new Error(`HTTP ${cheatsheetResp.status}`)
|
|
214
|
+
}
|
|
215
|
+
utils.write('CHEATSHEET.md', await cheatsheetResp.text())
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// Cosmetic — a reference doc that didn't download is no reason to fail
|
|
218
|
+
// a scaffold that is otherwise complete.
|
|
219
|
+
progress.warn(`Could not fetch CHEATSHEET.md (${error.message}) - skipped.`)
|
|
155
220
|
}
|
|
221
|
+
await progress.done()
|
|
156
222
|
|
|
157
|
-
|
|
223
|
+
progress.begin('install')
|
|
224
|
+
await installDependencies(progress, process.cwd())
|
|
225
|
+
await progress.done()
|
|
158
226
|
|
|
159
|
-
|
|
160
|
-
|
|
227
|
+
progress.begin('devserver')
|
|
228
|
+
await progress.complete()
|
|
229
|
+
} catch (error) {
|
|
230
|
+
progress.fail(error)
|
|
231
|
+
process.exitCode = 1
|
|
232
|
+
return
|
|
233
|
+
}
|
|
161
234
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
console.log('\n- - - - - Next Steps - - - - - ')
|
|
168
|
-
console.log('Open codebase in a code editor and navigate to /tools/index.ts - this is where your tool code goes.')
|
|
169
|
-
console.log('\nFor full documentation, visit https://docs.surph.ai\n')
|
|
170
|
-
// console.log('1. $ cd ' + name)
|
|
171
|
-
// console.log('2. $ npm install or $ yarn install')
|
|
172
|
-
// console.log('3. $ npm run dev')
|
|
173
|
-
// console.log('4. Open http://localhost:6767 in a browser')
|
|
235
|
+
console.log('\n- - - - - Next Steps - - - - - ')
|
|
236
|
+
console.log('Open codebase in a code editor and navigate to /tools/index.ts - this is where your tool code goes.')
|
|
237
|
+
console.log('\nFor full documentation, visit https://docs.surph.ai\n')
|
|
238
|
+
console.log('Starting the dev server - your browser will open at http://localhost:6767')
|
|
239
|
+
console.log('To stop it, press Ctrl+C. To start it again, run: npm run dev\n')
|
|
174
240
|
|
|
241
|
+
shell.exec('npm run dev')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = async (name, type = 'app') => {
|
|
245
|
+
/*
|
|
246
|
+
https://stackoverflow.com/questions/8683895/how-do-i-determine-the-current-operating-system-with-node-js
|
|
247
|
+
Returns the operating system platform. Possible values are 'darwin', 'freebsd', 'linux',
|
|
248
|
+
'sunos' or 'win32'. Returns the value of process.platform.
|
|
249
|
+
'darwin' == mac
|
|
250
|
+
'win32' == windows
|
|
251
|
+
*/
|
|
252
|
+
|
|
253
|
+
const { platform } = process
|
|
254
|
+
|
|
255
|
+
if (type === 'tool') {
|
|
256
|
+
await scaffoldTool(name)
|
|
175
257
|
}
|
|
176
258
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "npm run build && concurrently \"npm run watch\" \"surph toolserver\" \"surph chatserver\" \"surph browser
|
|
7
|
+
"dev": "npm run build && concurrently \"npm run watch\" \"surph toolserver\" \"surph chatserver\" \"surph browser http://localhost:6767\"",
|
|
8
8
|
"test": "",
|
|
9
9
|
"build": "rimraf dist && tsc",
|
|
10
10
|
"watch": "tsc -w",
|
package/utils/browser.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
const net = require('net')
|
|
2
|
+
const { execFile } = require('child_process')
|
|
3
|
+
const colors = require('colors/safe')
|
|
4
|
+
|
|
5
|
+
// Windows' cmd.exe does not treat single quotes as quoting, so a dev script
|
|
6
|
+
// written as `surph browser 'http://localhost:6767'` hands us a URL with the
|
|
7
|
+
// quotes still attached. Projects scaffolded before this fix keep that script
|
|
8
|
+
// and pull @surph_ai/cli@latest, so tolerating stray quotes here is what
|
|
9
|
+
// repairs them on upgrade.
|
|
10
|
+
const stripQuotes = (url) => {
|
|
11
|
+
let out = String(url == null ? '' : url).trim()
|
|
12
|
+
while (out.length >= 2) {
|
|
13
|
+
const first = out[0]
|
|
14
|
+
const last = out[out.length - 1]
|
|
15
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
16
|
+
out = out.slice(1, -1).trim()
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
break
|
|
20
|
+
}
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Only http(s) is ever opened — the URL reaches a shell-open handler, so
|
|
25
|
+
// anything else (file:, javascript:, a shell fragment) is rejected outright.
|
|
26
|
+
const parseUrl = (url) => {
|
|
27
|
+
let parsed
|
|
28
|
+
try {
|
|
29
|
+
parsed = new URL(url)
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
return parsed
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isLoopback = (hostname) => {
|
|
40
|
+
const host = (hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
41
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const urlPort = (parsed) => {
|
|
45
|
+
if (parsed.port) return Number(parsed.port)
|
|
46
|
+
return parsed.protocol === 'https:' ? 443 : 80
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const printManually = (url) => {
|
|
50
|
+
console.log(colors.yellow(`\nCouldn't open browser automatically. Open this URL manually:\n`))
|
|
51
|
+
console.log(colors.cyan(url) + '\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Cross-platform browser opener. Falls back to printing the URL — matters for
|
|
55
|
+
// SSH / headless / broken default handlers, where `open` silently no-ops.
|
|
56
|
+
// Resolves true when the opener exited cleanly, false when we fell back to
|
|
57
|
+
// printing; it never rejects, so callers can't be left hanging.
|
|
58
|
+
const openBrowser = (url) => new Promise((resolve) => {
|
|
59
|
+
const cleaned = stripQuotes(url)
|
|
60
|
+
const parsed = parseUrl(cleaned)
|
|
61
|
+
if (!parsed) {
|
|
62
|
+
printManually(cleaned)
|
|
63
|
+
resolve(false)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const target = parsed.toString()
|
|
68
|
+
// execFile (not exec) so the URL is passed as an argument rather than
|
|
69
|
+
// spliced into a shell command line. On Windows `start` is a cmd builtin,
|
|
70
|
+
// hence the explicit cmd.exe; the empty "" is the window title `start`
|
|
71
|
+
// would otherwise eat the URL as.
|
|
72
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [target]]
|
|
73
|
+
: process.platform === 'win32' ? [process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'start', '""', `"${target}"`]]
|
|
74
|
+
: ['xdg-open', [target]]
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
execFile(cmd, args, { windowsVerbatimArguments: process.platform === 'win32' }, (err) => {
|
|
78
|
+
if (err) {
|
|
79
|
+
printManually(target)
|
|
80
|
+
resolve(false)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
resolve(true)
|
|
84
|
+
})
|
|
85
|
+
} catch (_) {
|
|
86
|
+
printManually(target)
|
|
87
|
+
resolve(false)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Resolves true once something accepts a TCP connection on host:port, false
|
|
92
|
+
// if the wait window elapses first. Used so we open the browser after the
|
|
93
|
+
// toolserver is up instead of racing it onto a "connection refused" page.
|
|
94
|
+
const waitForPort = ({ port, host = '127.0.0.1', timeout = 60000, interval = 250 }) => new Promise((resolve) => {
|
|
95
|
+
const deadline = Date.now() + timeout
|
|
96
|
+
|
|
97
|
+
const attempt = () => {
|
|
98
|
+
const socket = new net.Socket()
|
|
99
|
+
let settled = false
|
|
100
|
+
|
|
101
|
+
const done = (ok) => {
|
|
102
|
+
if (settled) return
|
|
103
|
+
settled = true
|
|
104
|
+
socket.removeAllListeners()
|
|
105
|
+
socket.destroy()
|
|
106
|
+
if (ok) {
|
|
107
|
+
resolve(true)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (Date.now() >= deadline) {
|
|
111
|
+
resolve(false)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
// Deliberately not unref'd: between probes this timer is the only
|
|
115
|
+
// thing holding the event loop open, and `deadline` already bounds it.
|
|
116
|
+
setTimeout(attempt, interval)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Cap each probe so a black-holed connect can't outlive the deadline.
|
|
120
|
+
socket.setTimeout(Math.max(1, Math.min(interval * 4, deadline - Date.now())))
|
|
121
|
+
socket.once('connect', () => done(true))
|
|
122
|
+
socket.once('timeout', () => done(false))
|
|
123
|
+
socket.once('error', () => done(false))
|
|
124
|
+
socket.connect(port, host)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
attempt()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// `surph browser <url>`: tolerate stray quotes, wait for a local server to
|
|
131
|
+
// come up, then open. Never hangs — on timeout or failure the URL is printed.
|
|
132
|
+
const openBrowserWhenReady = async (url, { timeout = 60000 } = {}) => {
|
|
133
|
+
const cleaned = stripQuotes(url)
|
|
134
|
+
const parsed = parseUrl(cleaned)
|
|
135
|
+
if (!parsed) {
|
|
136
|
+
printManually(cleaned)
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (isLoopback(parsed.hostname)) {
|
|
141
|
+
const host = parsed.hostname === '0.0.0.0' ? '127.0.0.1' : parsed.hostname.replace(/^\[|\]$/g, '')
|
|
142
|
+
const ready = await waitForPort({ port: urlPort(parsed), host, timeout })
|
|
143
|
+
if (!ready) {
|
|
144
|
+
console.log(colors.yellow(`\nNothing is listening on ${host}:${urlPort(parsed)} after ${Math.round(timeout / 1000)}s.`))
|
|
145
|
+
printManually(parsed.toString())
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return openBrowser(parsed.toString())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { openBrowser, openBrowserWhenReady, waitForPort, stripQuotes }
|
package/utils/command.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const { spawn } = require('child_process')
|
|
2
|
+
|
|
3
|
+
// Keep the tail of a noisy command's output without letting a pathological
|
|
4
|
+
// run (a dependency that prints megabytes) grow unbounded in memory.
|
|
5
|
+
const MAX_OUTPUT = 200 * 1024
|
|
6
|
+
|
|
7
|
+
// Captures stdout/stderr; calls onLine per complete line. On failure, rejects with `.output`.
|
|
8
|
+
// Optional `retain(line)` filters what is kept in that tail (bounded by MAX_OUTPUT).
|
|
9
|
+
const runCaptured = (command, args, { cwd, env, onLine, retain } = {}) => new Promise((resolve, reject) => {
|
|
10
|
+
// npm ships as npm.cmd on Windows, which spawn() refuses to run without a
|
|
11
|
+
// shell. The args here are fixed literals, so nothing user-supplied is
|
|
12
|
+
// handed to cmd.exe. With a shell, Node wants a single command string:
|
|
13
|
+
// passing an args array alongside `shell: true` emits DEP0190 on stderr,
|
|
14
|
+
// which tears through the live progress bar.
|
|
15
|
+
const isWindows = process.platform === 'win32'
|
|
16
|
+
const bin = isWindows && command === 'npm' ? 'npm.cmd' : command
|
|
17
|
+
|
|
18
|
+
let child
|
|
19
|
+
try {
|
|
20
|
+
const options = {
|
|
21
|
+
cwd,
|
|
22
|
+
env: env || process.env,
|
|
23
|
+
shell: isWindows,
|
|
24
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
25
|
+
windowsHide: true
|
|
26
|
+
}
|
|
27
|
+
child = isWindows
|
|
28
|
+
? spawn([bin, ...args].join(' '), options)
|
|
29
|
+
: spawn(bin, args, options)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
reject(error)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let output = ''
|
|
36
|
+
let pending = ''
|
|
37
|
+
|
|
38
|
+
const keep = (line) => {
|
|
39
|
+
if (retain && !retain(line)) return
|
|
40
|
+
output += `${line}\n`
|
|
41
|
+
if (output.length > MAX_OUTPUT) {
|
|
42
|
+
output = output.slice(output.length - MAX_OUTPUT)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const collect = (chunk) => {
|
|
47
|
+
pending += chunk.toString()
|
|
48
|
+
const lines = pending.split(/\r?\n/)
|
|
49
|
+
pending = lines.pop()
|
|
50
|
+
lines.forEach((line) => {
|
|
51
|
+
keep(line)
|
|
52
|
+
if (onLine) onLine(line)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const flush = () => {
|
|
57
|
+
if (!pending) return
|
|
58
|
+
const line = pending
|
|
59
|
+
pending = ''
|
|
60
|
+
keep(line)
|
|
61
|
+
if (onLine) onLine(line)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
child.stdout.on('data', collect)
|
|
65
|
+
child.stderr.on('data', collect)
|
|
66
|
+
|
|
67
|
+
child.on('error', (error) => {
|
|
68
|
+
flush()
|
|
69
|
+
error.output = output
|
|
70
|
+
reject(error)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
child.on('close', (code, signal) => {
|
|
74
|
+
flush()
|
|
75
|
+
if (code === 0) {
|
|
76
|
+
resolve(output)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
const reason = signal ? `was killed with ${signal}` : `exited with code ${code}`
|
|
80
|
+
const error = new Error(`${command} ${args.join(' ')} ${reason}`)
|
|
81
|
+
error.output = output
|
|
82
|
+
reject(error)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
module.exports = { runCaptured }
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const cliProgress = require('cli-progress')
|
|
2
|
+
const colors = require('colors/safe')
|
|
3
|
+
|
|
4
|
+
const BAR_SIZE = 16
|
|
5
|
+
const MIN_WIDTH_FOR_BAR = 56
|
|
6
|
+
|
|
7
|
+
// cli-progress redraws at ~10 fps; sub-frame steps may not paint without a minimum dwell.
|
|
8
|
+
const MIN_VISIBLE_STEP_MS = 100
|
|
9
|
+
|
|
10
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
11
|
+
|
|
12
|
+
// Non-TTY output gets plain lines (no escape sequences).
|
|
13
|
+
const isInteractive = (stream = process.stdout) =>
|
|
14
|
+
Boolean(stream && stream.isTTY) && process.env.TERM !== 'dumb' && !process.env.CI
|
|
15
|
+
|
|
16
|
+
// Legacy conhost uses an OEM code page; WT and VS Code terminals are UTF-8.
|
|
17
|
+
const supportsUnicode = () => process.platform !== 'win32'
|
|
18
|
+
|| Boolean(process.env.WT_SESSION)
|
|
19
|
+
|| process.env.TERM_PROGRAM === 'vscode'
|
|
20
|
+
|
|
21
|
+
const GLYPHS = {
|
|
22
|
+
unicode: { tick: '✓', barDone: '█', barTodo: '░', ellipsis: '…' },
|
|
23
|
+
ascii: { tick: '+', barDone: '#', barTodo: '-', ellipsis: '...' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const oneLine = (text) => String(text == null ? '' : text)
|
|
27
|
+
.replace(/[\r\n]+/g, ' ')
|
|
28
|
+
// eslint-disable-next-line no-control-regex
|
|
29
|
+
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '')
|
|
30
|
+
.replace(/[\x00-\x1F\x7F]/g, ' ')
|
|
31
|
+
.replace(/\s+/g, ' ')
|
|
32
|
+
.trim()
|
|
33
|
+
|
|
34
|
+
const painter = (interactive) => (fn, text) => (interactive ? fn(text) : text)
|
|
35
|
+
|
|
36
|
+
const truncate = (text, max, ellipsis) => {
|
|
37
|
+
if (max <= ellipsis.length) return ''
|
|
38
|
+
return text.length > max ? text.slice(0, max - ellipsis.length) + ellipsis : text
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n)
|
|
42
|
+
|
|
43
|
+
// Weighted steps: one live progress line in TTY mode; completed steps log above it.
|
|
44
|
+
class StepProgress {
|
|
45
|
+
constructor(steps, options = {}) {
|
|
46
|
+
this.steps = steps.map((step) => Object.assign({ weight: 1 }, step))
|
|
47
|
+
this.stream = options.stream || process.stdout
|
|
48
|
+
this.interactive = options.interactive === undefined
|
|
49
|
+
? isInteractive(this.stream)
|
|
50
|
+
: options.interactive
|
|
51
|
+
this.heartbeatMs = options.heartbeatMs === undefined ? 15000 : options.heartbeatMs
|
|
52
|
+
this.minStepMs = options.minStepMs === undefined ? MIN_VISIBLE_STEP_MS : options.minStepMs
|
|
53
|
+
this.paint = painter(this.interactive)
|
|
54
|
+
this.glyph = GLYPHS[supportsUnicode() ? 'unicode' : 'ascii']
|
|
55
|
+
|
|
56
|
+
this.offsets = []
|
|
57
|
+
let running = 0
|
|
58
|
+
this.steps.forEach((step) => {
|
|
59
|
+
this.offsets.push(running)
|
|
60
|
+
running += step.weight
|
|
61
|
+
})
|
|
62
|
+
this.totalWeight = running || 1
|
|
63
|
+
|
|
64
|
+
this.index = -1
|
|
65
|
+
this.fraction = 0
|
|
66
|
+
this.detailText = ''
|
|
67
|
+
this.multi = null
|
|
68
|
+
this.bar = null
|
|
69
|
+
this.stepStartedAt = 0
|
|
70
|
+
this.lastHeartbeat = 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get step() { return this.index >= 0 ? this.steps[this.index] : null }
|
|
74
|
+
|
|
75
|
+
get width() { return Math.max(20, this.stream.columns || 80) }
|
|
76
|
+
|
|
77
|
+
get progress() {
|
|
78
|
+
if (this.index < 0) return 0
|
|
79
|
+
return clamp01((this.offsets[this.index] + this.fraction * this.step.weight) / this.totalWeight)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
formatLine() {
|
|
83
|
+
const width = this.width
|
|
84
|
+
const percentage = `${Math.floor(this.progress * 100)}`.padStart(3) + '%'
|
|
85
|
+
const step = this.step
|
|
86
|
+
const elapsed = Date.now() - this.stepStartedAt
|
|
87
|
+
const age = elapsed >= 2000 ? ` (${Math.round(elapsed / 1000)}s)` : ''
|
|
88
|
+
const label = this.detailText ? `${step.doing} - ${this.detailText}` : `${step.doing}...`
|
|
89
|
+
|
|
90
|
+
const fit = (room) => truncate(
|
|
91
|
+
label.length + age.length <= room ? label + age : label,
|
|
92
|
+
room,
|
|
93
|
+
this.glyph.ellipsis
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if (width < MIN_WIDTH_FOR_BAR) {
|
|
97
|
+
return `${percentage} ${fit(width - percentage.length - 2)}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const filled = Math.min(BAR_SIZE, Math.round(this.progress * BAR_SIZE))
|
|
101
|
+
const bar = this.glyph.barDone.repeat(filled) + this.glyph.barTodo.repeat(BAR_SIZE - filled)
|
|
102
|
+
const head = ` ${bar} ${percentage} `
|
|
103
|
+
return ` ${this.paint(colors.cyan, bar)} ${percentage} ` + fit(width - head.length - 1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
logLine(marker, text, color) {
|
|
107
|
+
const body = truncate(text, this.width - 3 - marker.length, this.glyph.ellipsis)
|
|
108
|
+
const line = ` ${color ? this.paint(color, marker) : marker} ${body}`
|
|
109
|
+
if (this.multi) {
|
|
110
|
+
this.multi.log(`${line}\n`)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
this.stream.write(`${line}\n`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
start() {
|
|
117
|
+
if (!this.interactive) return
|
|
118
|
+
this.multi = new cliProgress.MultiBar({
|
|
119
|
+
format: () => this.formatLine(),
|
|
120
|
+
stream: this.stream,
|
|
121
|
+
hideCursor: true,
|
|
122
|
+
clearOnComplete: true,
|
|
123
|
+
gracefulExit: true,
|
|
124
|
+
etaAsynchronousUpdate: false,
|
|
125
|
+
forceRedraw: true
|
|
126
|
+
})
|
|
127
|
+
this.bar = this.multi.create(this.totalWeight, 0)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
render() {
|
|
131
|
+
if (this.bar) {
|
|
132
|
+
this.bar.update(this.progress * this.totalWeight)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
begin(id) {
|
|
137
|
+
const index = this.steps.findIndex((step) => step.id === id)
|
|
138
|
+
if (index === -1) {
|
|
139
|
+
throw new Error(`unknown scaffolding step: ${id}`)
|
|
140
|
+
}
|
|
141
|
+
this.index = index
|
|
142
|
+
this.fraction = 0
|
|
143
|
+
this.detailText = ''
|
|
144
|
+
this.stepStartedAt = Date.now()
|
|
145
|
+
this.lastHeartbeat = this.stepStartedAt
|
|
146
|
+
if (this.interactive) {
|
|
147
|
+
this.render()
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
this.logLine('>', `${this.steps[index].doing}...`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
advance(fraction, detail) {
|
|
154
|
+
if (this.index < 0) return
|
|
155
|
+
this.fraction = Math.max(this.fraction, clamp01(fraction))
|
|
156
|
+
if (detail !== undefined) this.detail(detail)
|
|
157
|
+
else this.render()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
detail(text) {
|
|
161
|
+
if (this.index < 0) return
|
|
162
|
+
const clean = oneLine(text)
|
|
163
|
+
if (this.interactive) {
|
|
164
|
+
this.detailText = clean
|
|
165
|
+
this.render()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (this.heartbeatMs <= 0) return
|
|
169
|
+
const now = Date.now()
|
|
170
|
+
if (now - this.lastHeartbeat < this.heartbeatMs) return
|
|
171
|
+
this.lastHeartbeat = now
|
|
172
|
+
const seconds = Math.round((now - this.stepStartedAt) / 1000)
|
|
173
|
+
this.logLine(' ', `still working: ${clean || this.step.doing} (${seconds}s)`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async done(note) {
|
|
177
|
+
if (this.index < 0) return
|
|
178
|
+
const elapsed = Date.now() - this.stepStartedAt
|
|
179
|
+
if (this.interactive && elapsed < this.minStepMs) {
|
|
180
|
+
this.advance(0.6)
|
|
181
|
+
await sleep(this.minStepMs - elapsed)
|
|
182
|
+
}
|
|
183
|
+
this.fraction = 1
|
|
184
|
+
this.detailText = ''
|
|
185
|
+
this.render()
|
|
186
|
+
const seconds = ((Date.now() - this.stepStartedAt) / 1000).toFixed(1)
|
|
187
|
+
const suffix = note ? `${note}, ${seconds}s` : `${seconds}s`
|
|
188
|
+
this.logLine(this.glyph.tick, `${this.step.done} (${suffix})`, colors.green)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
warn(message) {
|
|
192
|
+
this.logLine('!', message, colors.yellow)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
stop() {
|
|
196
|
+
if (this.multi) {
|
|
197
|
+
this.multi.update() // flush multi.log buffer before stop()
|
|
198
|
+
this.multi.stop()
|
|
199
|
+
this.multi = null
|
|
200
|
+
this.bar = null
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async complete() {
|
|
205
|
+
if (this.index >= 0) await this.done()
|
|
206
|
+
this.index = this.steps.length - 1
|
|
207
|
+
this.fraction = 1
|
|
208
|
+
this.render()
|
|
209
|
+
this.stop()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fail(error) {
|
|
213
|
+
const step = this.step ? this.step.doing : 'startup'
|
|
214
|
+
this.stop()
|
|
215
|
+
console.log(this.paint(colors.red, `\nFAILED while ${step.charAt(0).toLowerCase()}${step.slice(1)}`))
|
|
216
|
+
console.log(` ${error && error.message ? error.message : error}\n`)
|
|
217
|
+
const output = error && error.output ? String(error.output).trim() : ''
|
|
218
|
+
if (output) {
|
|
219
|
+
console.log(this.paint(colors.gray, '--- output ---'))
|
|
220
|
+
console.log(output)
|
|
221
|
+
console.log(this.paint(colors.gray, '--------------') + '\n')
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { StepProgress, isInteractive, oneLine, painter, supportsUnicode }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const { spawn } = require('child_process')
|
|
2
|
+
|
|
3
|
+
// --- Why this file exists -------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// Windows' legacy console host (conhost, what `cmd.exe` and the Win+R "Windows
|
|
6
|
+
// PowerShell" window use) ships with **QuickEdit Mode on by default**. With it
|
|
7
|
+
// on, a single click anywhere in the window puts the console into selection
|
|
8
|
+
// mode, and selection mode *blocks the attached process's writes to stdout*.
|
|
9
|
+
// A CLI that redraws a progress line then appears frozen — which is exactly
|
|
10
|
+
// what QA reported: the progress messages stop updating and never recover
|
|
11
|
+
// until the user presses Esc or right-clicks.
|
|
12
|
+
//
|
|
13
|
+
// There is no command-line flag for this. `powershell.exe` has no switch that
|
|
14
|
+
// turns QuickEdit off, and neither does `cmd.exe` or `conhost.exe` — the flag
|
|
15
|
+
// QuickEdit lives behind is a *console mode bit*, ENABLE_QUICK_EDIT_MODE
|
|
16
|
+
// (0x0040) on the console input buffer, and the only two supported ways to
|
|
17
|
+
// clear it are:
|
|
18
|
+
//
|
|
19
|
+
// 1. `SetConsoleMode()` on CONIN$ at runtime — affects the console this
|
|
20
|
+
// process is attached to, takes effect immediately. This is what we do.
|
|
21
|
+
// 2. HKCU\Console\QuickEdit = 0 in the registry — only applies to console
|
|
22
|
+
// windows opened *afterwards*, and permanently changes a user-global
|
|
23
|
+
// preference. Rejected: it cannot fix the window we are already running
|
|
24
|
+
// in, and silently rewriting a user's terminal defaults is not ours to do.
|
|
25
|
+
//
|
|
26
|
+
// Node has no binding for SetConsoleMode, so option 1 goes through PowerShell,
|
|
27
|
+
// which is present on every Windows 10 and 11 install. The console input
|
|
28
|
+
// buffer is a property of the *console*, not of a process, so a child process
|
|
29
|
+
// that opens CONIN$ changes the mode for our window too.
|
|
30
|
+
//
|
|
31
|
+
// The mode is restored when we exit: the helper holds its stdin open and waits,
|
|
32
|
+
// so when this process ends (cleanly, on Ctrl+C, or killed) the pipe closes and
|
|
33
|
+
// the helper puts the user's QuickEdit setting back. Leaving it off would cost
|
|
34
|
+
// them click-to-copy in that window for as long as it stays open.
|
|
35
|
+
|
|
36
|
+
const ENABLE_QUICK_EDIT_MODE = 0x0040
|
|
37
|
+
const ENABLE_EXTENDED_FLAGS = 0x0080
|
|
38
|
+
|
|
39
|
+
// ENABLE_EXTENDED_FLAGS must be set in the same call for the QuickEdit bit to
|
|
40
|
+
// be honoured; the remaining bits are carried over from whatever the user had.
|
|
41
|
+
const SCRIPT = `
|
|
42
|
+
$ErrorActionPreference = 'Stop'
|
|
43
|
+
try {
|
|
44
|
+
Add-Type -TypeDefinition @"
|
|
45
|
+
using System;
|
|
46
|
+
using System.Runtime.InteropServices;
|
|
47
|
+
public static class SurphConsole {
|
|
48
|
+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
49
|
+
public static extern IntPtr CreateFileW(string name, uint access, uint share, IntPtr sec, uint disposition, uint flags, IntPtr template);
|
|
50
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
51
|
+
public static extern bool GetConsoleMode(IntPtr handle, out uint mode);
|
|
52
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
53
|
+
public static extern bool SetConsoleMode(IntPtr handle, uint mode);
|
|
54
|
+
}
|
|
55
|
+
"@
|
|
56
|
+
} catch { exit 1 }
|
|
57
|
+
|
|
58
|
+
# Decimal, not 0xC0000000: PowerShell parses a hex literal that fits in 32 bits
|
|
59
|
+
# as a *signed* Int32, so 0xC0000000 arrives as -1073741824 and binding it to a
|
|
60
|
+
# uint parameter throws outright. 3221225472 is GENERIC_READ | GENERIC_WRITE.
|
|
61
|
+
# The 3s are FILE_SHARE_READ | FILE_SHARE_WRITE and OPEN_EXISTING.
|
|
62
|
+
#
|
|
63
|
+
# CONIN$ resolves to the console we inherited from the CLI, which is the point:
|
|
64
|
+
# GetStdHandle would hand back the pipe our parent gave us instead.
|
|
65
|
+
$handle = [SurphConsole]::CreateFileW('CONIN$', [uint32]3221225472, [uint32]3, [IntPtr]::Zero, [uint32]3, [uint32]0, [IntPtr]::Zero)
|
|
66
|
+
if ($handle -eq [IntPtr]::Zero -or $handle.ToInt64() -eq -1) { exit 1 }
|
|
67
|
+
|
|
68
|
+
$mode = [uint32]0
|
|
69
|
+
if (-not [SurphConsole]::GetConsoleMode($handle, [ref]$mode)) { exit 1 }
|
|
70
|
+
|
|
71
|
+
# Already off (the user, or their profile, turned it off) - leave it alone.
|
|
72
|
+
if (($mode -band ${ENABLE_QUICK_EDIT_MODE}) -eq 0) { exit 0 }
|
|
73
|
+
|
|
74
|
+
# Widened to Int64 first so the mixed UInt32/Int32 bitwise operands cannot
|
|
75
|
+
# surprise us on the way back down to the uint the API wants.
|
|
76
|
+
$patched = [uint32](([int64]$mode -bor ${ENABLE_EXTENDED_FLAGS}) -band (-bnot ${ENABLE_QUICK_EDIT_MODE}))
|
|
77
|
+
if (-not [SurphConsole]::SetConsoleMode($handle, $patched)) { exit 1 }
|
|
78
|
+
|
|
79
|
+
# Block until the CLI exits and our stdin pipe closes, then hand the user's
|
|
80
|
+
# console back the way we found it.
|
|
81
|
+
try { $null = [Console]::In.ReadToEnd() } catch { }
|
|
82
|
+
[void][SurphConsole]::SetConsoleMode($handle, $mode)
|
|
83
|
+
`
|
|
84
|
+
|
|
85
|
+
// Windows Terminal and the VS Code terminal drive the console through ConPTY
|
|
86
|
+
// and do their own text selection, which never stalls the writer — the
|
|
87
|
+
// QuickEdit bit is inert there, so spawning PowerShell would only cost startup
|
|
88
|
+
// time for nothing.
|
|
89
|
+
const needsQuickEditFix = () => process.platform === 'win32'
|
|
90
|
+
&& Boolean(process.stdout && process.stdout.isTTY)
|
|
91
|
+
&& !process.env.WT_SESSION
|
|
92
|
+
&& process.env.TERM_PROGRAM !== 'vscode'
|
|
93
|
+
&& process.env.SURPH_NO_CONSOLE_FIX !== '1'
|
|
94
|
+
|
|
95
|
+
// Best-effort and entirely silent: a console we could not reconfigure is a
|
|
96
|
+
// slightly worse progress display, never a failed command. Returns the helper
|
|
97
|
+
// process when one was started, otherwise null.
|
|
98
|
+
const prepareWindowsConsole = () => {
|
|
99
|
+
if (!needsQuickEditFix()) return null
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// -EncodedCommand takes base64 UTF-16LE and sidesteps cmd.exe's quoting
|
|
103
|
+
// rules entirely, which matters because the script contains quotes, `$`
|
|
104
|
+
// and `@"` here-strings.
|
|
105
|
+
const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64')
|
|
106
|
+
const child = spawn('powershell.exe', [
|
|
107
|
+
'-NoProfile',
|
|
108
|
+
'-NonInteractive',
|
|
109
|
+
'-ExecutionPolicy', 'Bypass',
|
|
110
|
+
'-EncodedCommand', encoded
|
|
111
|
+
], {
|
|
112
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
113
|
+
windowsHide: true
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
child.on('error', () => {})
|
|
117
|
+
if (child.stdin) child.stdin.on('error', () => {})
|
|
118
|
+
|
|
119
|
+
// Neither the process handle nor the stdin pipe should keep the CLI alive;
|
|
120
|
+
// the helper's whole job is to outlive nothing.
|
|
121
|
+
child.unref()
|
|
122
|
+
if (child.stdin && child.stdin.unref) child.stdin.unref()
|
|
123
|
+
|
|
124
|
+
return child
|
|
125
|
+
} catch (_) {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { prepareWindowsConsole, needsQuickEditFix }
|