-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Expand file tree
/
Copy pathdesktop.ts
More file actions
273 lines (253 loc) · 10.2 KB
/
Copy pathdesktop.ts
File metadata and controls
273 lines (253 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { openBrowser } from './cli-auth'
import { discoverConfigurationSources } from './configuration-sources'
import { SetupError } from './errors'
import { httpHealth } from './probes'
import * as p from './prompter'
import { glyph, theme } from './theme'
import { APP_URL } from './urls'
/**
* Where a deployment redirects to the newest installer for its own channel,
* and the manifest installed shells poll. Both ship in every Sim deployment
* (`app/api/desktop/update/*`), so a self-hosted install already serves them —
* there is nothing to build or host.
*/
const DOWNLOAD_PATH = '/api/desktop/update/download'
const FEED_PATH = '/api/desktop/update/latest-mac.yml'
const PROBE_TIMEOUT_MS = 15_000
const REDIRECT_STATUSES: ReadonlySet<number> = new Set([301, 302, 307, 308])
/** The env var every deployment sets to its own public origin. */
const APP_URL_KEY = 'NEXT_PUBLIC_APP_URL'
/**
* The deployment a configured URL names, for comparison only. Unparseable
* values fall back to their raw text so they compare equal to themselves and
* unequal to everything else.
*/
function deploymentKey(value: string): string {
try {
return new URL(value).origin.toLowerCase()
} catch {
return value
}
}
/** Keeps a rendered artifact name from overrunning the spinner line. */
const MAX_INSTALLER_NAME = 120
/**
* Reduces an artifact name to printable text before it reaches a TTY.
*
* The name is read out of a redirect the deployment chose, so it is remote
* input, and `decodeURIComponent` turns percent-encoded bytes into the real
* characters. Enumerating what to strip invited a patch per escape found — C0
* and C1, then the bidi overrides, then the line separators — so this keeps
* whole Unicode groups instead: `C` (Other) removes every control, format,
* surrogate, private-use, and unassigned code point, covering ESC and OSC, the
* bidi overrides and isolates, zero-width characters, and the BOM; `Z`
* (Separator) removes every space, line, and paragraph separator, covering
* U+2028/U+2029 and no-break spaces.
*
* Separators become a plain space rather than vanishing, so a name is not
* silently run together at the seam, and runs are collapsed so the result
* cannot be padded out to push text off the line.
*/
export function sanitizeForTerminal(value: string): string {
const printable = value
.replace(/\p{C}/gu, '')
.replace(/\p{Z}/gu, ' ')
.replace(/ {2,}/g, ' ')
.trim()
return truncate(printable, MAX_INSTALLER_NAME)
}
export interface DesktopFlags {
/** Overrides the deployment origin when the CLI runs away from the install. */
url?: string
noOpen: boolean
}
/**
* The deployment origin the desktop app should be pointed at.
*
* Read from every discovered source, not only the one `add` may write: an
* operator running a Helm release or an external Compose project still needs
* the URL, and reading it changes nothing.
*
* Sources that disagree are an error rather than a first-match win. This
* command probes a URL, prints it as the one to trust, and offers to open it —
* so silently preferring whichever source enumerated first would point an
* operator with both a local checkout and a real deployment at localhost and
* never say so. `resolveFeatureSetupDestination` refuses ambiguity the same way.
*/
export function resolveDeploymentUrl(
sources: readonly { label?: string; values?: Map<string, string> | null }[],
override?: string
): string {
const discovered = sources.flatMap((source) => {
const value = source.values?.get(APP_URL_KEY)?.trim()
return value ? [{ label: source.label ?? 'configuration', value }] : []
})
// Compared on the parsed origin, which is what the command ultimately uses:
// a trailing slash, a default port, a different host case, or an ignored path
// all name the same deployment, and calling those a conflict would demand a
// --url override to resolve an ambiguity that does not exist. A value that
// will not parse is its own bucket so it still reaches the error below,
// which says something more useful than "these disagree".
const byDeployment = new Map<string, string>()
for (const { value } of discovered) {
byDeployment.set(deploymentKey(value), value)
}
if (!override && byDeployment.size > 1) {
throw new SetupError(
`Found ${byDeployment.size} configurations naming different ${APP_URL_KEY} values.`,
[
...discovered.map(({ label, value }) => `${label}: ${value}`),
'Re-run with --url <deployment url> to say which one the desktop app should use.',
]
)
}
const raw = override ?? byDeployment.values().next().value
if (!raw) {
// A wizard-provisioned local install has the compose interpolation default
// rather than an explicit value, so an absent key is not a misconfiguration.
return APP_URL
}
let url: URL
try {
url = new URL(raw.trim())
} catch {
throw new SetupError(`${APP_URL_KEY} is not a valid URL: ${raw}`, [
'Set it to the origin browsers use to reach Sim, e.g. https://sim.example.com',
])
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new SetupError(`${APP_URL_KEY} must be an http(s) URL: ${raw}`)
}
return url.origin
}
export type DesktopProbe =
| { status: 'ok'; installerUrl: string; installerName: string }
| { status: 'no-release' }
| { status: 'feed-unavailable' }
| { status: 'unreachable'; error: string }
| { status: 'unexpected'; code: number }
/**
* Asks the deployment to resolve its own installer, following no redirects:
* the redirect's Location IS the answer, and downloading the artifact here
* would pull hundreds of megabytes to check a link.
*/
export async function probeDownload(
downloadUrl: string,
fetchImpl: typeof fetch = fetch
): Promise<DesktopProbe> {
let response: Response
try {
response = await fetchImpl(downloadUrl, {
redirect: 'manual',
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
})
} catch (error) {
return { status: 'unreachable', error: getErrorMessage(error, 'request failed') }
}
if (REDIRECT_STATUSES.has(response.status)) {
const location = response.headers.get('location')
if (!location) return { status: 'unexpected', code: response.status }
let name = location
try {
name = decodeURIComponent(new URL(location).pathname.split('/').pop() ?? location)
} catch {
// Keep the raw Location; it is still the most useful thing to print.
}
return { status: 'ok', installerUrl: location, installerName: sanitizeForTerminal(name) }
}
if (response.status === 404) return { status: 'no-release' }
if (response.status === 502) return { status: 'feed-unavailable' }
return { status: 'unexpected', code: response.status }
}
/**
* One exhaustive switch for both the headline and its follow-up hints, so a
* new {@link DesktopProbe} status cannot compile with its hints silently
* missing — which a `default` arm would have allowed.
*/
export function describeProbe(
probe: DesktopProbe,
appUrl: string
): { headline: string; hints: readonly string[] } {
switch (probe.status) {
case 'ok':
return { headline: `${glyph.pass} Installer resolved: ${probe.installerName}`, hints: [] }
case 'no-release':
return {
headline: `${glyph.fail} This deployment reports no desktop release for its channel.`,
hints: [
'Stable desktop builds are published on GitHub releases of simstudioai/sim.',
'A brand-new fork with no releases of its own will report this.',
],
}
case 'feed-unavailable':
return {
headline: `${glyph.fail} The deployment could not reach the GitHub release feed.`,
hints: [
'The Sim server needs outbound access to api.github.com and github.com.',
'Unauthenticated GitHub API calls are capped at 60/hour per IP — set GITHUB_TOKEN on the Sim server to raise it to 5000/hour.',
],
}
case 'unreachable':
return {
headline: `${glyph.fail} Could not reach ${appUrl} — ${probe.error}`,
hints: [
`Check that Sim is running and reachable at ${appUrl} (npx sim-setup status).`,
'Pass --url if this machine reaches Sim at a different address.',
],
}
case 'unexpected':
return { headline: `${glyph.fail} The download endpoint answered ${probe.code}.`, hints: [] }
}
}
export async function runDesktop(flags: DesktopFlags): Promise<number> {
// Discovery shells out to `docker compose ls/config` and `helm list/get
// values`, so it is seconds of blocking work — and every bit of it is
// discarded when --url already names the deployment.
const appUrl = resolveDeploymentUrl(flags.url ? [] : discoverConfigurationSources(), flags.url)
const downloadUrl = `${appUrl}${DOWNLOAD_PATH}`
p.log.step(`Deployment: ${theme.accent(appUrl)}`)
const spin = p.spinner()
spin.start('Resolving the desktop installer…')
const [probe, feedOk] = await Promise.all([
probeDownload(downloadUrl),
httpHealth(`${appUrl}${FEED_PATH}`, PROBE_TIMEOUT_MS),
])
const { headline, hints } = describeProbe(probe, appUrl)
spin.stop(headline)
if (probe.status !== 'ok') {
for (const hint of hints) {
p.log.info(hint)
}
p.outro(theme.error('The desktop installer could not be resolved.'))
return 1
}
if (!feedOk) {
p.log.warn(
`${FEED_PATH} did not resolve — the app will install but will not auto-update from this deployment.`
)
}
p.note(
[
`1. Download and install Sim:`,
` ${theme.accent(downloadUrl)}`,
'',
`2. Open Sim, then choose ${theme.command('Sim → Server…')} in the menu bar.`,
'',
`3. Enter your server URL and press Connect:`,
` ${theme.accent(appUrl)}`,
'',
theme.muted('Sim relaunches against your deployment and updates from it from then on.'),
theme.muted('The desktop app is macOS-only today; the web app works everywhere.'),
].join('\n'),
'Connect the desktop app'
)
if (!flags.noOpen) {
if (await p.confirm({ message: 'Download it now?', initialValue: true })) {
openBrowser(downloadUrl)
}
}
p.outro(theme.accent('Ready.'))
return 0
}