@symbo.ls/cli 2.33.34 → 2.33.35

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.
Files changed (2) hide show
  1. package/bin/login.js +214 -48
  2. package/package.json +5 -5
package/bin/login.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  import inquirer from 'inquirer'
4
4
  import chalk from 'chalk'
5
+ import crypto from 'crypto'
6
+ import { spawn } from 'child_process'
5
7
  import { program } from './program.js'
6
8
  import { getApiUrl, saveCliConfig, loadCliConfig } from '../helpers/config.js'
7
9
  import { CredentialManager } from '../helpers/credentialManager.js'
@@ -10,7 +12,7 @@ function websiteFromApi(apiBaseUrl) {
10
12
  try {
11
13
  const u = new URL(apiBaseUrl)
12
14
  const host = u.host
13
- if (apiBaseUrl.startsWith('http://localhost')) return 'http://localhost:1024'
15
+ if (apiBaseUrl.startsWith('http://localhost')) return 'https://2dd379117f83.ngrok-free.app'
14
16
  if (host === 'api.dev.symbols.app') return 'https://dev.symbols.app'
15
17
  if (host === 'api.staging.symbols.app') return 'https://staging.symbols.app'
16
18
  if (host === 'api.test.symbols.app') return 'https://test.symbols.app'
@@ -23,6 +25,82 @@ function websiteFromApi(apiBaseUrl) {
23
25
  }
24
26
  }
25
27
 
28
+ function openBrowser(url) {
29
+ try {
30
+ const platform = process.platform
31
+ if (platform === 'darwin') {
32
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
33
+ return true
34
+ }
35
+ if (platform === 'win32') {
36
+ // Use cmd's "start" to open default browser
37
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref()
38
+ return true
39
+ }
40
+ // linux, etc.
41
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
42
+ return true
43
+ } catch (_) {
44
+ return false
45
+ }
46
+ }
47
+
48
+ function extractAuthFromResponse(data) {
49
+ const user = data?.data?.user || data?.user
50
+ const tokens = data?.data?.tokens || data?.tokens || {}
51
+ const accessToken =
52
+ tokens?.accessToken ||
53
+ data?.token ||
54
+ data?.accessToken ||
55
+ data?.jwt ||
56
+ data?.data?.token ||
57
+ data?.data?.accessToken ||
58
+ data?.data?.jwt
59
+ const refreshToken = tokens?.refreshToken || null
60
+ const accessTokenExp = tokens?.accessTokenExp?.expiresAt || tokens?.accessTokenExp || null
61
+ return { user, accessToken, refreshToken, accessTokenExp }
62
+ }
63
+
64
+ function randomVerifier(len = 64) {
65
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'
66
+ let out = ''
67
+ for (let i = 0; i < len; i++) out += chars.charAt(Math.floor(Math.random() * chars.length))
68
+ return out
69
+ }
70
+
71
+ function base64urlFromBuffer(buf) {
72
+ return Buffer.from(buf)
73
+ .toString('base64')
74
+ .replace(/\+/g, '-')
75
+ .replace(/\//g, '_')
76
+ .replace(/=+$/g, '')
77
+ }
78
+
79
+ function sha256Base64url(input) {
80
+ const digest = crypto.createHash('sha256').update(input).digest()
81
+ return base64urlFromBuffer(digest)
82
+ }
83
+
84
+ function extractSessionStatus(data) {
85
+ return (
86
+ data?.status ||
87
+ data?.data?.status ||
88
+ data?.state ||
89
+ data?.data?.state ||
90
+ null
91
+ )
92
+ }
93
+
94
+ function extractConfirmToken(data) {
95
+ return (
96
+ data?.access_token ||
97
+ data?.data?.access_token ||
98
+ data?.token ||
99
+ data?.data?.token ||
100
+ null
101
+ )
102
+ }
103
+
26
104
  program
27
105
  .command('login')
28
106
  .description('Sign in to Symbols')
@@ -33,7 +111,7 @@ program
33
111
 
34
112
  // Prompt for credentials
35
113
  const currentConfig = loadCliConfig()
36
- const answers = await inquirer.prompt([
114
+ const first = await inquirer.prompt([
37
115
  {
38
116
  type: 'input',
39
117
  name: 'apiBaseUrl',
@@ -42,72 +120,160 @@ program
42
120
  validate: input => /^https?:\/\//.test(input) || '❌ Please enter a valid URL'
43
121
  },
44
122
  {
45
- type: 'input',
46
- name: 'email',
47
- message: 'Email:',
48
- validate: input => input.includes('@') || '❌ Please enter a valid email address'
123
+ type: 'list',
124
+ name: 'method',
125
+ message: 'Sign in method:',
126
+ choices: [
127
+ { name: 'Email + Password', value: 'password' },
128
+ // Uses plugin-style PKCE session flow so providers don't need localhost redirects
129
+ { name: 'Continue with Google (opens browser)', value: 'google_browser' },
130
+ { name: 'Continue with GitHub (opens browser)', value: 'github_browser' },
131
+ ],
132
+ default: 'password',
49
133
  },
50
- {
51
- type: 'password',
52
- name: 'password',
53
- message: 'Password:',
54
- validate: input => input.length >= 6 || '❌ Password must be at least 6 characters'
55
- }
56
134
  ])
57
135
 
58
136
  try {
59
- // Make login request
60
- console.log(chalk.dim('\nAuthenticating...'))
61
- const response = await fetch(`${answers.apiBaseUrl}/core/auth/login`, {
62
- method: 'POST',
63
- headers: {
64
- 'Content-Type': 'application/json'
65
- },
66
- body: JSON.stringify({
67
- email: answers.email,
68
- password: answers.password
137
+ let data
138
+ let apiBaseUrl = first.apiBaseUrl
139
+
140
+ if (first.method === 'password') {
141
+ const answers = await inquirer.prompt([
142
+ {
143
+ type: 'input',
144
+ name: 'email',
145
+ message: 'Email:',
146
+ validate: input => input.includes('@') || '❌ Please enter a valid email address'
147
+ },
148
+ {
149
+ type: 'password',
150
+ name: 'password',
151
+ message: 'Password:',
152
+ validate: input => input.length >= 6 || '❌ Password must be at least 6 characters'
153
+ }
154
+ ])
155
+
156
+ // Make login request
157
+ console.log(chalk.dim('\nAuthenticating...'))
158
+ const response = await fetch(`${apiBaseUrl}/core/auth/login`, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({ email: answers.email, password: answers.password })
69
162
  })
70
- })
71
163
 
72
- const data = await response.json()
164
+ data = await response.json()
165
+
166
+ if (!response.ok) {
167
+ const msg = data?.message || data?.error || `Authentication failed (${response.status})`
168
+ const err = new Error(msg)
169
+ err.response = { status: response.status, data }
170
+ throw err
171
+ }
172
+ } else if (first.method === 'google_browser' || first.method === 'github_browser') {
173
+ const sessionId = crypto.randomUUID()
174
+ const codeVerifier = randomVerifier(64)
175
+ const codeChallenge = sha256Base64url(codeVerifier)
176
+
177
+ console.log(chalk.dim('\nCreating secure sign-in session...'))
178
+ const sessionResp = await fetch(`${apiBaseUrl}/core/auth/session`, {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({
182
+ session_id: sessionId,
183
+ code_challenge: codeChallenge,
184
+ plugin_info: { version: 'cli', figma_env: 'cli' }
185
+ })
186
+ })
187
+
188
+ const sessionData = await sessionResp.json().catch(async () => ({ message: await sessionResp.text() }))
189
+ if (!sessionResp.ok) {
190
+ const msg = sessionData?.message || sessionData?.error || `Failed to create session (${sessionResp.status})`
191
+ throw new Error(msg)
192
+ }
193
+
194
+ const website = websiteFromApi(apiBaseUrl)
195
+ const signinUrl = `${website.replace(/\/+$/,'')}/signin?session=${encodeURIComponent(sessionId)}`
196
+
197
+ console.log(chalk.dim('\nOpening your browser to complete sign-in...'))
198
+ if (!openBrowser(signinUrl)) {
199
+ console.log(chalk.yellow('\nCould not auto-open a browser. Please open this URL manually:\n'))
200
+ console.log(chalk.cyan(signinUrl) + '\n')
201
+ }
202
+
203
+ console.log(chalk.gray('Waiting for you to finish signing in (Ctrl+C to cancel)...'))
204
+
205
+ const startedAt = Date.now()
206
+ const timeoutMs = 3 * 60 * 1000
207
+ const pollMs = 1500
208
+
209
+ // Poll until server says session is ready_for_confirm
210
+ // (Website login/register will attach ?session=... to mark the session ready)
211
+ while (true) {
212
+ if (Date.now() - startedAt > timeoutMs) {
213
+ throw new Error('Timed out waiting for sign-in to complete in the browser')
214
+ }
215
+
216
+ const statusResp = await fetch(
217
+ `${apiBaseUrl}/core/auth/session/status?session=${encodeURIComponent(sessionId)}`
218
+ )
219
+ const statusData = await statusResp.json().catch(async () => ({ message: await statusResp.text() }))
220
+
221
+ if (!statusResp.ok) {
222
+ const msg = statusData?.message || statusData?.error || `Failed to check session status (${statusResp.status})`
223
+ throw new Error(msg)
224
+ }
225
+
226
+ const status = extractSessionStatus(statusData)
227
+ if (status === 'ready_for_confirm') break
228
+ if (status === 'expired' || status === 'revoked' || status === 'invalid') {
229
+ throw new Error(`Session became unusable (${status})`)
230
+ }
231
+
232
+ await new Promise(resolve => setTimeout(resolve, pollMs))
233
+ }
234
+
235
+ console.log(chalk.dim('Confirming session...'))
236
+ const confirmResp = await fetch(`${apiBaseUrl}/core/auth/session/confirm`, {
237
+ method: 'POST',
238
+ headers: { 'Content-Type': 'application/json' },
239
+ body: JSON.stringify({ session_id: sessionId, code_verifier: codeVerifier })
240
+ })
241
+
242
+ const confirmData = await confirmResp.json().catch(async () => ({ message: await confirmResp.text() }))
243
+ if (!confirmResp.ok) {
244
+ const msg = confirmData?.message || confirmData?.error || `Failed to confirm session (${confirmResp.status})`
245
+ throw new Error(msg)
246
+ }
247
+
248
+ const token = extractConfirmToken(confirmData)
249
+ if (!token) {
250
+ throw new Error('Sign-in succeeded but no access token was returned by the server')
251
+ }
73
252
 
74
- if (!response.ok) {
75
- const msg = data?.message || data?.error || `Authentication failed (${response.status})`
76
- const err = new Error(msg)
77
- err.response = { status: response.status, data }
78
- throw err
253
+ // Normalize shape to reuse existing credential save logic
254
+ data = { token }
255
+ } else {
256
+ throw new Error('Unknown login method')
79
257
  }
80
258
 
81
- // Extract token from various possible shapes
82
- const user = data?.data?.user || data?.user
83
- const tokens = data?.data?.tokens || data?.tokens || {}
84
- const token =
85
- tokens?.accessToken ||
86
- data?.token ||
87
- data?.accessToken ||
88
- data?.jwt ||
89
- data?.data?.token ||
90
- data?.data?.accessToken ||
91
- data?.data?.jwt
92
- if (!token) {
259
+ const { user, accessToken, refreshToken, accessTokenExp } = extractAuthFromResponse(data)
260
+ if (!accessToken) {
93
261
  throw new Error('Login succeeded but no token was returned by the server')
94
262
  }
95
- const refreshToken = tokens?.refreshToken || null
96
- const accessTokenExp = tokens?.accessTokenExp?.expiresAt || null
97
263
 
98
264
  // Save credentials
99
265
  const credManager = new CredentialManager()
100
266
  credManager.saveCredentials({
101
- apiBaseUrl: answers.apiBaseUrl,
102
- authToken: token,
267
+ apiBaseUrl,
268
+ authToken: accessToken,
103
269
  refreshToken,
104
270
  authTokenExpiresAt: accessTokenExp,
105
271
  userId: user?.id || data?.userId,
106
- email: user?.email || answers.email
272
+ email: user?.email || null
107
273
  })
108
274
 
109
275
  // Persist API base URL to local config for this project as well
110
- saveCliConfig({ apiBaseUrl: answers.apiBaseUrl })
276
+ saveCliConfig({ apiBaseUrl })
111
277
 
112
278
  console.log(chalk.green('\n✨ Successfully logged in!'))
113
279
  console.log(chalk.white('\nYou can now use Symbols CLI commands:'))
@@ -120,7 +286,7 @@ program
120
286
  console.log(chalk.dim('For more commands, run: smbls --help\n'))
121
287
 
122
288
  } catch (error) {
123
- const website = websiteFromApi(answers.apiBaseUrl)
289
+ const website = websiteFromApi(first.apiBaseUrl)
124
290
  console.log(chalk.red('\n❌ Login failed'))
125
291
  console.log(chalk.white('\nError:'))
126
292
  console.log(chalk.yellow(error.message || 'Unknown error'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/cli",
3
- "version": "2.33.34",
3
+ "version": "2.33.35",
4
4
  "description": "Fetch your Symbols configuration",
5
5
  "main": "bin/fetch.js",
6
6
  "author": "Symbols",
@@ -15,9 +15,9 @@
15
15
  "vpatch": "npm version patch && npm publish"
16
16
  },
17
17
  "dependencies": {
18
- "@symbo.ls/fetch": "^2.33.34",
19
- "@symbo.ls/init": "^2.33.34",
20
- "@symbo.ls/socket": "^2.33.34",
18
+ "@symbo.ls/fetch": "^2.33.35",
19
+ "@symbo.ls/init": "^2.33.35",
20
+ "@symbo.ls/socket": "^2.33.35",
21
21
  "chalk": "^5.4.1",
22
22
  "chokidar": "^4.0.3",
23
23
  "commander": "^13.1.0",
@@ -28,5 +28,5 @@
28
28
  "socket.io-client": "^4.8.1",
29
29
  "v8-compile-cache": "^2.4.0"
30
30
  },
31
- "gitHead": "14a204110bf402fb4bb09d449ccc9abd1b19ef48"
31
+ "gitHead": "3ad860e75a24c41afaac79d639f7a9162c2f4c81"
32
32
  }