@surph_ai/cli 0.0.10 → 0.0.13

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
@@ -9,6 +9,10 @@
9
9
  const { Command } = require('commander')
10
10
  const path = require('path')
11
11
  const fs = require('fs')
12
+ const os = require('os')
13
+ const http = require('http')
14
+ const crypto = require('crypto')
15
+ const { exec } = require('child_process')
12
16
  const dotenv = require('dotenv')
13
17
  const prompt = require('prompt')
14
18
  const shell = require('shelljs')
@@ -22,9 +26,19 @@ const utils = require('./utils')
22
26
  const packageJson = require('./package.json')
23
27
 
24
28
  const VERSION = packageJson.version
25
- const SURPH_REST_URL = 'https://surph-mongo-http-19075ff8d93f.herokuapp.com'
29
+ // Env overrides let the flow point at a local surph.ai / REST during dev
30
+ // without editing this file. Defaults are production.
31
+ const SURPH_REST_URL = process.env.SURPH_REST_URL || 'https://surph-mongo-http-19075ff8d93f.herokuapp.com'
32
+
33
+ const SURPH_WEB_URL = 'https://surph.ai'
26
34
  // const RPC_URL = 'https://e3lunom2hka3u6iiotrlzrcez40uwdnk.lambda-url.us-east-1.on.aws'
27
35
 
36
+ // User-scoped session — one login covers every project on this machine,
37
+ // matching gh / gcloud / claude. Was previously project-scoped
38
+ // (../../../.surph/session.json inside the tool project).
39
+ const SESSION_PATH = path.join(os.homedir(), '.surph', 'session.json')
40
+ const CLI_LOGIN_TIMEOUT_MS = 2 * 60 * 1000
41
+
28
42
  // FUTURE: Export as a referenceable type
29
43
  const ERRORS = {
30
44
  // General errors
@@ -40,13 +54,12 @@ const ERRORS = {
40
54
 
41
55
  const surphSession = async () => {
42
56
  try {
43
- const userSessionPath = path.resolve(__dirname, '../../../.surph/session.json')
44
- if (!fs.existsSync(userSessionPath)) {
57
+ if (!fs.existsSync(SESSION_PATH)) {
45
58
  console.log('Not logged in.')
46
59
  return null
47
60
  }
48
-
49
- const file = fs.readFileSync(userSessionPath, 'utf-8')
61
+
62
+ const file = fs.readFileSync(SESSION_PATH, 'utf-8')
50
63
  const session = JSON.parse(file)
51
64
  return session
52
65
  } catch (error) {
@@ -55,6 +68,45 @@ const surphSession = async () => {
55
68
  }
56
69
  }
57
70
 
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
+ const loginSuccessHtml = (username) => `<!doctype html>
86
+ <html>
87
+ <head><meta charset="utf-8"><title>Surph — Logged in</title>
88
+ <style>
89
+ html,body{margin:0;height:100%;background:#0b0b0b;color:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
90
+ .wrap{display:flex;align-items:center;justify-content:center;height:100%}
91
+ .card{text-align:center;max-width:420px;padding:2rem}
92
+ h1{font-weight:500;margin:0 0 .5rem;font-size:1.5rem}
93
+ p{margin:0;color:rgba(245,245,245,.65);line-height:1.5}
94
+ .ok{color:#4ade80;font-size:2rem;margin-bottom:1rem}
95
+ </style></head>
96
+ <body><div class="wrap"><div class="card">
97
+ <div class="ok">✓</div>
98
+ <h1>Logged in${username ? ` as @${username}` : ''}</h1>
99
+ <p>You can close this tab and return to your terminal.</p>
100
+ </div></div></body></html>`
101
+
102
+ const loginErrorHtml = (message) => `<!doctype html>
103
+ <html><head><meta charset="utf-8"><title>Surph — Login failed</title></head>
104
+ <body style="font-family:-apple-system,sans-serif;padding:2rem">
105
+ <h1>Login failed</h1>
106
+ <p>${message}</p>
107
+ <p>You may close this tab and try again from the terminal.</p>
108
+ </body></html>`
109
+
58
110
  const program = new Command()
59
111
 
60
112
  program.version(VERSION, '-v, --version', 'output the current version')
@@ -77,6 +129,127 @@ program.command('version')
77
129
  }
78
130
  })
79
131
 
132
+ program.command('login')
133
+ .description('log in to Surph')
134
+ .action(async () => {
135
+ let server
136
+ try {
137
+ // State nonce binds the browser round-trip to this specific CLI
138
+ // invocation — rejects stray callbacks (stale tab, replay, CSRF).
139
+ const state = crypto.randomBytes(16).toString('hex')
140
+
141
+ let resolveCallback
142
+ let rejectCallback
143
+ const callbackPromise = new Promise((resolve, reject) => {
144
+ resolveCallback = resolve
145
+ rejectCallback = reject
146
+ })
147
+
148
+ server = http.createServer((req, res) => {
149
+ // Ignore anything except the /callback path. Browsers ping
150
+ // /favicon.ico etc. — respond quietly so they don't fill the terminal.
151
+ const reqUrl = new URL(req.url, 'http://127.0.0.1')
152
+ if (reqUrl.pathname !== '/callback') {
153
+ res.writeHead(404)
154
+ res.end()
155
+ return
156
+ }
157
+
158
+ const token = reqUrl.searchParams.get('token')
159
+ const returnedState = reqUrl.searchParams.get('state')
160
+ const errParam = reqUrl.searchParams.get('error')
161
+
162
+ if (errParam) {
163
+ res.writeHead(400, { 'Content-Type': 'text/html' })
164
+ res.end(loginErrorHtml(errParam))
165
+ rejectCallback(new Error(errParam))
166
+ return
167
+ }
168
+ if (!token || !returnedState || returnedState !== state) {
169
+ res.writeHead(400, { 'Content-Type': 'text/html' })
170
+ res.end(loginErrorHtml('Invalid state or missing token.'))
171
+ rejectCallback(new Error('invalid state or missing token'))
172
+ return
173
+ }
174
+
175
+ // Success page is written first; user lookup happens after.
176
+ // Response is intentionally best-effort — we already have the token.
177
+ res.writeHead(200, { 'Content-Type': 'text/html' })
178
+ res.end(loginSuccessHtml(null))
179
+ resolveCallback(token)
180
+ })
181
+
182
+ // Bind port 0 = pick any free port. 127.0.0.1 (not 0.0.0.0) so nothing
183
+ // on the LAN can hit the callback and race for the token.
184
+ await new Promise((resolve, reject) => {
185
+ server.on('error', reject)
186
+ server.listen(0, '127.0.0.1', resolve)
187
+ })
188
+ const { port } = server.address()
189
+ const callback = `http://127.0.0.1:${port}/callback`
190
+ const authUrl = `${SURPH_WEB_URL}/auth/cli/login?callback=${encodeURIComponent(callback)}&state=${state}`
191
+
192
+ console.log(colors.cyan('\nOpening browser to complete login...\n'))
193
+ console.log(colors.gray(`If it doesn't open, visit this URL manually:\n${authUrl}\n`))
194
+ openBrowser(authUrl)
195
+
196
+ // Hard timeout — otherwise a user who never completes login leaves the
197
+ // CLI hanging forever with a bound port.
198
+ const timeoutId = setTimeout(
199
+ () => rejectCallback(new Error(`login timed out after ${CLI_LOGIN_TIMEOUT_MS / 1000}s`)),
200
+ CLI_LOGIN_TIMEOUT_MS
201
+ )
202
+
203
+ let token
204
+ try {
205
+ token = await callbackPromise
206
+ } finally {
207
+ clearTimeout(timeoutId)
208
+ server.close()
209
+ }
210
+
211
+ // Exchange the token for the user record. Hits the web server (same
212
+ // origin as the login redirect), not the mongo-http REST — the token
213
+ // is signed by the web server's HMAC secret and only it can verify.
214
+ const resp = await fetch(`${SURPH_WEB_URL}/auth/cli/whoami`, {
215
+ method: 'GET',
216
+ headers: {
217
+ Accept: 'application/json',
218
+ Authorization: `Bearer ${token}`,
219
+ 'x-surph-client': 'surph-sdk'
220
+ }
221
+ })
222
+ if (!resp.ok) {
223
+ throw new Error(`whoami request failed: HTTP ${resp.status}`)
224
+ }
225
+ const body = await resp.json()
226
+ const user = body?.payload || body?.user || body
227
+ if (!user?._id) {
228
+ throw new Error('whoami response missing user _id')
229
+ }
230
+
231
+ const session = {
232
+ token,
233
+ userId: user._id,
234
+ username: user.username || null,
235
+ avatar: user.avatar || null,
236
+ createdAt: new Date().toISOString()
237
+ }
238
+
239
+ fs.mkdirSync(path.dirname(SESSION_PATH), { recursive: true })
240
+ // Restrict to owner rw so a shared machine doesn't leak the token to
241
+ // other local users. No-op on Windows.
242
+ fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2) + '\n', { mode: 0o600 })
243
+
244
+ const who = session.username ? `@${session.username}` : session.userId
245
+ console.log(colors.brightGreen(`\n✓ Logged in as ${who}\n`))
246
+ } catch (error) {
247
+ if (server) { try { server.close() } catch (_) {} }
248
+ console.log(colors.red(`\nLogin failed: ${error.message}\n`))
249
+ process.exit(1)
250
+ }
251
+ })
252
+
80
253
  program.command('profile')
81
254
  .description('Current Surph user')
82
255
  .action(async (cmd, options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surph_ai/cli",
3
- "version": "0.0.10",
3
+ "version": "0.0.13",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "types": "./types.d.ts",
@@ -17,7 +17,6 @@
17
17
  "author": "SurphAI",
18
18
  "license": "ISC",
19
19
  "dependencies": {
20
- "@aws-sdk/client-bedrock-runtime": "^3.1096.0",
21
20
  "adm-zip": "^0.5.10",
22
21
  "bestzip": "^2.2.1",
23
22
  "cheerio": "^1.2.0",
package/scaffold/index.js CHANGED
@@ -146,7 +146,7 @@ module.exports = (name, type = 'app') => {
146
146
  console.log('1. $ cd ' + name)
147
147
  console.log('2. $ npm install or $ yarn install')
148
148
  console.log('3. $ npm run dev')
149
- console.log('4. Open http://localhost:5000 in a browser')
149
+ console.log('4. Open http://localhost:6767 in a browser')
150
150
  console.log('\nFor full documentation, visit https://docs.surph.ai\n')
151
151
 
152
152
  }
@@ -1,2 +1,2 @@
1
1
  SURPH_TOOL=<SURPH_TOOL>
2
- PORT=5000
2
+ PORT=6767
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "tool-template",
2
+ "name": "test5",
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
5
  "type": "module",
@@ -8,6 +8,7 @@
8
8
  "test": "",
9
9
  "build": "rimraf dist && tsc",
10
10
  "watch": "tsc -w",
11
+ "login": "surph login",
11
12
  "manifest": "surph manifest",
12
13
  "publish": "npm run build && surph publish",
13
14
  "deploy": "npm run build && surph deploy"
package/toolserver/app.js CHANGED
@@ -1,24 +1,17 @@
1
1
  const express = require('express')
2
+ const path = require('path')
2
3
  const vinely = require('@vinely-ai/sdk')
3
- const { config } = require('./middleware/index.js')
4
+ const { config, configureApp } = require('./middleware/index.js')
4
5
 
5
6
  const app = express()
6
7
 
7
- vinely.configureApp(app, {
8
- views: 'node_modules/@surph_ai/cli/toolserver/templates',
8
+ configureApp(app, {
9
+ views: path.join(__dirname, 'templates'),
9
10
  static: 'public',
10
11
  })
11
12
 
12
13
  app.use(config)
13
14
 
14
- // app.use((req, res, next) => {
15
- // res.setHeader('Access-Control-Allow-Origin', '*')
16
- // res.header('Access-Control-Allow-Headers', 'x-surph-user, Content-Type');
17
- // res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
18
- // if (req.method === 'OPTIONS') return res.sendStatus(204);
19
- // next()
20
- // })
21
-
22
15
  const api = require('./routes/api')
23
16
  const tool = require('./routes/tool')
24
17
  const index = require('./routes/index')
@@ -0,0 +1,63 @@
1
+ const express = require('express')
2
+ const path = require('path')
3
+ // const logger = require('morgan')
4
+ const cookieParser = require('cookie-parser')
5
+ const bodyParser = require('body-parser')
6
+ const sessions = require('client-sessions')
7
+ const { renderFile } = require('ejs')
8
+ const engines = require('consolidate')
9
+
10
+ const BASE_PATH = process.cwd()
11
+
12
+ const configureSession = (app) => {
13
+ const opts = app.opts
14
+ if (!opts.session)
15
+ return
16
+
17
+ const { session } = opts
18
+ const cookieName = session.cookieName || 'surphSession'
19
+ const secret = session.secret || process.env.SESSION_SECRET
20
+ const duration = session.duration || 24 * 60 * 60 * 1000
21
+ const activeDuration = session.activeDuration || 30 * 60 * 1000
22
+
23
+ app.use(sessions({
24
+ cookieName,
25
+ secret,
26
+ duration,
27
+ activeDuration,
28
+ }))
29
+ }
30
+
31
+ module.exports = (app, opts) => { // opts is optional
32
+ if (!app) // should never happen
33
+ return
34
+
35
+ app.opts = opts || {}
36
+ app.use(bodyParser.json({ limit: '10mb' }))
37
+ app.use(bodyParser.urlencoded({extended: false}))
38
+ app.use(cookieParser())
39
+ // app.use(logger('dev'))
40
+ configureSession(app)
41
+
42
+ const viewsPath = app.opts.views
43
+ ? path.resolve(BASE_PATH, app.opts.views)
44
+ : path.join(__dirname, '..', 'templates')
45
+ app.set('views', viewsPath)
46
+
47
+ // default to mustache template engine:
48
+ const viewEngine = app.opts.viewEngine || 'mustache'
49
+
50
+ if (viewEngine === 'mustache') {
51
+ app.engine('html', engines.mustache)
52
+ app.set('view engine', 'html')
53
+ }
54
+
55
+ if (viewEngine === 'ejs') {
56
+ app.set('view engine', 'html')
57
+ app.engine('html', renderFile)
58
+ }
59
+
60
+ const staticDir = app.opts.static || 'public'
61
+ const staticPath = path.resolve(BASE_PATH, staticDir)
62
+ app.use(express.static(staticPath, {index: false })) // { index:false } prevents index.html from overriding template engine
63
+ }
@@ -1,5 +1,7 @@
1
1
  const config = require('./config.js')
2
+ const configureApp = require('./configureApp.js')
2
3
 
3
4
  module.exports = {
4
5
  config,
6
+ configureApp,
5
7
  }
@@ -1,15 +1,57 @@
1
1
  const { Router } = require('express')
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const fs = require('fs')
2
5
  const router = Router()
3
6
  const manifest = require('../../../../../manifest.json')
4
7
 
8
+ // Written by `surph login` — user-scoped, cross-platform via os.homedir().
9
+ // Resolves to ~/.surph/session.json on mac/linux and C:\Users\<name>\.surph\
10
+ // session.json on windows.
11
+ const SESSION_PATH = path.join(os.homedir(), '.surph', 'session.json')
12
+
13
+ // Re-read on every request so `surph login` / `surph logout` take effect
14
+ // on the next browser refresh without needing to restart the toolserver.
15
+ // Cheap: single sync fs read of a tiny JSON file on localhost.
16
+ const readSurphSession = () => {
17
+ try {
18
+ if (!fs.existsSync(SESSION_PATH)) return null
19
+ return JSON.parse(fs.readFileSync(SESSION_PATH, 'utf-8'))
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ const fetchUser = async (username) => {
26
+ try {
27
+ const response = await fetch(`https://surph.ai/api/users?username=${username}`)
28
+ const { payload: users } = await response.json()
29
+ if (users.length === 0) {
30
+ throw new Error(`User ${username} not found.`)
31
+ }
32
+
33
+ return users[0]
34
+ } catch (error) {
35
+ return null
36
+ }
37
+ }
38
+
5
39
  router.get('/', async (req, res, next) => {
6
40
  try {
7
41
  const toolSlugs = Object.keys(manifest)
8
42
 
43
+ // Mirror `userId` to `_id` (and `id`) so preloaded.user matches the
44
+ // production shape the web app consumes — useSettings.js reads
45
+ // `preloaded.user` and downstream code branches on `_id`.
46
+ const session = readSurphSession()
47
+ const user = session?.userId? await fetchUser(session.username) : null
48
+
9
49
  const context = {
10
50
  env: 'sandbox',
51
+ mode: 'desktop',
11
52
  config: { ...req.config, entry: 'web' },
12
53
  tools: toolSlugs.map(slug => (manifest[slug])),
54
+ user,
13
55
  }
14
56
 
15
57
  res.render('index', {
@@ -19,7 +61,7 @@ router.get('/', async (req, res, next) => {
19
61
  })
20
62
  } catch (error) {
21
63
  res.json({
22
- error: 'something went wrong'
64
+ error: `something went wrong: ${error.message}`
23
65
  })
24
66
  }
25
67
  })