@surph_ai/cli 0.0.26 → 0.0.28

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 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
- .action(url => openBrowser(url))
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('\nSurph tool updated.')
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.26",
3
+ "version": "0.0.28",
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
- // Cross-platform browser opener. Falls back to printing the URL — matters for
9
- // SSH / headless / broken default handlers, where `open` silently no-ops.
10
- const openBrowser = (url) => {
11
- const cmd = process.platform === 'darwin' ? `open "${url}"`
12
- : process.platform === 'win32' ? `start "" "${url}"`
13
- : `xdg-open "${url}"`
14
- exec(cmd, (err) => {
15
- if (err) {
16
- console.log(colors.yellow(`\nCouldn't open browser automatically. Open this URL manually:\n`))
17
- console.log(colors.cyan(url) + '\n')
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
- module.exports = async (name, type = 'app') => {
62
- /*
63
- https://stackoverflow.com/questions/8683895/how-do-i-determine-the-current-operating-system-with-node-js
64
- Returns the operating system platform. Possible values are 'darwin', 'freebsd', 'linux',
65
- 'sunos' or 'win32'. Returns the value of process.platform.
66
- 'darwin' == mac
67
- 'win32' == windows
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
- const { platform } = process
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
- if (type === 'tool') {
73
- console.log('create tool: ' + name)
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
- // shell.mkdir('-p', 'examples/dealfinder')
97
- // shell.cp('-R', path.join(__dirname, `tool/examples/dealfinder/index.ts`), 'examples/dealfinder/index.ts')
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
- const tsConfig = {
139
- "compilerOptions": {
140
- "target": "ES2022",
141
- "module": "Node16",
142
- "moduleResolution": "Node16",
143
- "lib": ["ES2022"],
144
- "types": ["node"],
145
- "outDir": "./dist",
146
- "rootDir": ".",
147
- "strict": true,
148
- "esModuleInterop": true,
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
- utils.write('tsconfig.json', JSON.stringify(tsConfig, null, 2) + '\n')
223
+ progress.begin('install')
224
+ await installDependencies(progress, process.cwd())
225
+ await progress.done()
158
226
 
159
- const cheatsheetResp = await fetch('https://surph.s3.us-east-1.amazonaws.com/tools/cli/CHEATSHEET.md')
160
- utils.write('CHEATSHEET.md', await cheatsheetResp.text())
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
- shell.cd(name)
163
- shell.exec('npm install')
164
- shell.exec('npm run dev')
165
- openBrowser('http://localhost:6767')
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 'http://localhost:6767'\"",
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",
@@ -29,20 +29,22 @@ const readSurphSession = () => {
29
29
 
30
30
  router.get('/', async (req, res, next) => {
31
31
  try {
32
+ // res.json({data: 'test'})
33
+ // return
32
34
  const toolSlugs = Object.keys(manifest)
33
35
 
34
36
  // Mirror `userId` to `_id` (and `id`) so preloaded.user matches the
35
37
  // production shape the web app consumes — useSettings.js reads
36
38
  // `preloaded.user` and downstream code branches on `_id`.
37
39
  const session = readSurphSession()
38
- // const { user, contexts, tides, tools } = session?.userId? await userConfiguration({ id: session.userId }) : null
40
+ // const { user, contexts, waves, tools } = session?.userId? await userConfiguration({ id: session.userId }) : null
39
41
  const userConfig = session?.userId? await userConfiguration({ id: session.userId }) : null
40
42
 
41
43
  let title = 'Surph AI'
42
44
  let ctx = null
43
45
  let board = null
44
46
  let tools = null
45
- let tides = null
47
+ let waves = null
46
48
  let contexts = null
47
49
 
48
50
  if (req.surph_board) {
@@ -51,15 +53,15 @@ router.get('/', async (req, res, next) => {
51
53
  title = req.surph_board.name
52
54
 
53
55
  tools = req.surph_board.tools
54
- tides = req.surph_board.tides
56
+ waves = req.surph_board.waves
55
57
  contexts = req.surph_board.contexts
56
58
  } else if (userConfig) {
57
59
  tools = userConfig.tools
58
- tides = userConfig.tides
60
+ waves = userConfig.waves
59
61
  contexts = userConfig.contexts
60
62
  } else { // no user logged in, set empty:
61
63
  tools = []
62
- tides = []
64
+ waves = []
63
65
  contexts = []
64
66
  }
65
67
 
@@ -67,17 +69,17 @@ router.get('/', async (req, res, next) => {
67
69
  const creator = userConfig ? { _id: user._id, username: user.username, avatar: user.avatar } : {}
68
70
  const localTools = toolSlugs.map(slug => {
69
71
  const tool = manifest[slug]
70
- let isTideTool = false
71
- tides.forEach(t => {
72
+ let isWaveTool = false
73
+ waves.forEach(t => {
72
74
  if (!t.tools)
73
75
  return
74
76
 
75
77
  if (t.tools.indexOf(slug) > -1) {
76
- isTideTool = true
78
+ isWaveTool = true
77
79
  }
78
80
  })
79
81
 
80
- return isTideTool ? { ...tool, creator, origin: 'tide' } : { ...tool, creator }
82
+ return isWaveTool ? { ...tool, creator, origin: 'wave' } : { ...tool, creator }
81
83
  })
82
84
 
83
85
  const context = {
@@ -89,7 +91,7 @@ router.get('/', async (req, res, next) => {
89
91
  user,
90
92
  config: { ...req.config, entry: 'web' },
91
93
  contexts,
92
- tides,
94
+ waves,
93
95
  tools: localTools // sdk mode is for local tool development only, do not clutter with live tools.
94
96
  // tools: [ ...tools, ...localTools ]
95
97
  }
@@ -32,10 +32,18 @@ const boardConfiguration = async (boardSlug) => {
32
32
  }
33
33
  });
34
34
  const { payload } = await response.json();
35
- BOARD_CACHE[boardSlug] = payload;
36
- // console.log('PAYLOAD: ' + JSON.stringify(payload));
37
- const { board, contexts, tides, tools } = payload;
38
- return { board, contexts, tides, tools };
35
+ // console.log('PAYLOAD: ' + JSON.stringify(payload))
36
+ if (!payload)
37
+ return null;
38
+ if (!payload.board)
39
+ return null;
40
+ // Tides are retired: drop the board's legacy tide list (still on the
41
+ // Mongo record and in the query payload) so nothing downstream sees it.
42
+ const { tides: _tides, ...board } = payload.board;
43
+ const { tides: _boardTides, ...config } = payload;
44
+ const result = { ...config, board };
45
+ BOARD_CACHE[boardSlug] = result;
46
+ return result;
39
47
  }
40
48
  catch (error) {
41
49
  console.error('Error fetching board: ', error.message);
@@ -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 }
@@ -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 }
@@ -1,29 +1,56 @@
1
1
  const REST_API_URL = 'https://surph-mongo-http-19075ff8d93f.herokuapp.com'
2
2
 
3
- // fetch user contexts, threads, tides, etc.
4
- const userConfiguration = async (user, includeTides = true) => {
5
- try {
6
- const userId = user?._id || user?.id || null;
7
- if (!userId) return null;
8
-
9
- const response = await fetch(`${REST_API_URL}/queries/user_configuration`, {
10
- method: 'POST',
11
- body: JSON.stringify({ user: userId}),
12
- headers: {
13
- 'Accept': 'application/json',
14
- 'Content-type': 'application/json',
15
- 'x-surph-client': 'surph-webapp',
16
- }
17
- })
18
-
19
- const { payload } = await response.json()
20
- const { user: _user, contexts, tides, tools } = payload
21
- // console.log('PAYLOAD: ' + JSON.stringify({ contexts, tides, tools }))
22
-
23
- return { user: _user, contexts, tides, tools }
24
- } catch (error) {
25
- throw error
26
- }
27
- }
3
+ // The user_configuration query also adds the tools attached to the user's
4
+ // waves, tagged `origin: 'wave'` (often a second copy of an installed tool).
5
+ // Keep only the ones the user created, untagged, and one entry per slug.
6
+ const withoutWaveTools = (tools, userId) => {
7
+ if (!Array.isArray(tools))
8
+ return tools;
9
+ const uid = String(userId);
10
+ const seen = new Set();
11
+ const out = [];
12
+ for (const t of tools) {
13
+ if (!t)
14
+ continue;
15
+ if (t.origin === 'wave') {
16
+ const creatorId = t.creator?._id || t.creator?.id || null;
17
+ if (creatorId == null || String(creatorId) !== uid)
18
+ continue;
19
+ }
20
+ const { origin: _origin, ...tool } = t;
21
+ const key = tool.slug || String(tool._id || '');
22
+ if (key && seen.has(key))
23
+ continue;
24
+ if (key)
25
+ seen.add(key);
26
+ out.push(tool);
27
+ }
28
+ return out;
29
+ };
30
+ // fetch user contexts, waves, tools, boards, etc.
31
+ const userConfiguration = async (user) => {
32
+ try {
33
+ const userId = user?._id || user?.id || null;
34
+ if (!userId)
35
+ return null;
36
+ const response = await fetch(`${REST_API_URL}/queries/user_configuration`, {
37
+ method: 'POST',
38
+ body: JSON.stringify({ user: userId }),
39
+ headers: {
40
+ 'Accept': 'application/json',
41
+ 'Content-type': 'application/json',
42
+ 'x-surph-client': 'surph-webapp',
43
+ }
44
+ });
45
+ const { payload } = await response.json();
46
+ const { user: _user, contexts, waves, tools, boards, templates } = payload;
47
+ // Tides are retired: drop the user's legacy subscribed-tide list.
48
+ const { tides: _tides, ...userRecord } = _user || {};
49
+ return { user: _user ? userRecord : _user, contexts, waves, tools: withoutWaveTools(tools, userId), boards, templates };
50
+ }
51
+ catch (error) {
52
+ throw error;
53
+ }
54
+ };
28
55
 
29
56
  module.exports = userConfiguration;
@@ -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 }