centralu 0.1.0-beta.2 → 0.1.0-beta.21

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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * The launcher's per-platform decisions, as plain functions of the platform.
3
+ *
4
+ * Lives apart from `centralu.mjs` for the same reason `semver.mjs` does: the launcher runs
5
+ * a command at import time, so a test cannot import it. Everything here takes the platform
6
+ * (and the environment, and the home folder) as an argument instead of reading `process`,
7
+ * so `tooling/launcher-platform.test.ts` can ask what Windows would do from a Mac — which
8
+ * is the only place this project can run a test before the Windows package ships.
9
+ *
10
+ * package.json "files" lists the whole bin/ directory, so this ships automatically.
11
+ */
12
+ import { posix, win32 } from 'node:path'
13
+
14
+ export const APP_NAME = 'Centralu'
15
+
16
+ /**
17
+ * The arch package for each machine, and what the app is called inside it.
18
+ *
19
+ * `artifact` must match `scripts/release-npm.mts` exactly — that script renames what
20
+ * Tauri produced (`Centralu_0.1.0-beta.2_amd64.AppImage`) to a fixed name precisely so
21
+ * the launcher does not have to know version or architecture. If the two ever disagree
22
+ * the symptom is "installed fine, does nothing".
23
+ *
24
+ * Only combinations that are actually published belong here. Listing a package that was
25
+ * never released would turn "not supported yet" into "your install is broken", which
26
+ * sends the user off to reinstall something that can never appear.
27
+ * `tooling/launcher-platform.test.ts` holds this table to the shim's `optionalDependencies`.
28
+ *
29
+ * Windows ships a folder, not a single file: `Centralu\centralu.exe` beside
30
+ * `Centralu\resources\host\`. Tauri looks for its resources next to the exe when nothing
31
+ * is installed, so the folder runs where npm unpacked it (#14, W1 in #307).
32
+ */
33
+ export const TARGETS = {
34
+ 'darwin-arm64': { pkg: '@centralu/darwin-arm64', artifact: `${APP_NAME}.app` },
35
+ 'linux-arm64': { pkg: '@centralu/linux-arm64', artifact: `${APP_NAME}.AppImage` },
36
+ 'linux-x64': { pkg: '@centralu/linux-x64', artifact: `${APP_NAME}.AppImage` },
37
+ 'win32-x64': { pkg: '@centralu/win32-x64', artifact: APP_NAME },
38
+ }
39
+
40
+ /** The target for a platform/arch pair, or null when nothing is published for it. */
41
+ export function targetFor(platform, arch) {
42
+ return TARGETS[`${platform}-${arch}`] ?? null
43
+ }
44
+
45
+ /**
46
+ * The file that proves the artifact is there and is what gets started.
47
+ *
48
+ * On macOS and Linux that is the artifact itself (a bundle handed to `open`, an AppImage
49
+ * run directly). On Windows the artifact is a folder, and a folder that exists without its
50
+ * exe — an interrupted extract — must count as missing, not as found.
51
+ */
52
+ export function executableIn(platform, app) {
53
+ return platform === 'win32' ? win32.join(app, 'centralu.exe') : app
54
+ }
55
+
56
+ /**
57
+ * The bundled host (`resources/host`, the folder whose `main.mjs` the app runs) inside a
58
+ * platform package, for `centralu serve` (#82).
59
+ *
60
+ * - macOS: inside the bundle, where Tauri puts `bundle.resources`.
61
+ * - Windows: beside the exe, which is already how the folder ships (W1 in #307).
62
+ * - Linux: a `host/` folder next to the AppImage. The host inside the AppImage sits in a
63
+ * squashfs that is only reachable by mounting it (FUSE, which headless servers often lack)
64
+ * or by extracting it on every start, so `scripts/release-npm.mts` ships the same folder a
65
+ * second time, unpacked. It is 11 MB; the AppImage is several times that.
66
+ */
67
+ export function hostDirIn(platform, app) {
68
+ if (platform === 'darwin') return posix.join(app, 'Contents', 'Resources', 'resources', 'host')
69
+ if (platform === 'win32') return win32.join(app, 'resources', 'host')
70
+ return posix.join(posix.dirname(app), 'host')
71
+ }
72
+
73
+ /**
74
+ * The host alone, without the window, for the machines a hub installs on (docs/plans/remote-hub.md
75
+ * §10.2, owner decision 2 of §10.9): `host/` at the top of the package on every platform. The shim
76
+ * never pins these (an npm install gets the host inside its platform package already); a hub's
77
+ * installer fetches one beside this package instead of the platform package, so a Linux remote does
78
+ * not download an 81 MB AppImage to throw away. Published from the same release jobs as the platform
79
+ * packages (`scripts/host-package.mts`); `tooling/host-package.test.ts` holds this table to them.
80
+ */
81
+ export const HOST_PACKAGES = {
82
+ 'linux-arm64': '@centralu/host-linux-arm64',
83
+ 'linux-x64': '@centralu/host-linux-x64',
84
+ 'win32-x64': '@centralu/host-win32-x64',
85
+ }
86
+
87
+ /**
88
+ * Where `centralu serve` finds the host's `main.mjs`: in the platform package (an npm install), else
89
+ * in the host-only package (what a hub installed, `<data>/remote/versions/<v>/node_modules/`).
90
+ *
91
+ * `resolveRoot(name)` is the package's folder or null when it is not installed; `exists(file)` says
92
+ * whether a file is there. Answers `{ entry }`, or `{ missingHost: { pkg, entry } }` when a package
93
+ * is installed without its host, or `{ missingPackage: true }` when neither is installed.
94
+ */
95
+ export function findHostEntry(platform, arch, { resolveRoot, exists }) {
96
+ const join = platform === 'win32' ? win32.join : posix.join
97
+ const target = targetFor(platform, arch)
98
+ const hostOnly = HOST_PACKAGES[`${platform}-${arch}`]
99
+ const places = []
100
+ if (target) places.push({ pkg: target.pkg, dir: (root) => hostDirIn(platform, join(root, target.artifact)) })
101
+ if (hostOnly) places.push({ pkg: hostOnly, dir: (root) => join(root, 'host') })
102
+ let missingHost = null
103
+ for (const place of places) {
104
+ const root = resolveRoot(place.pkg)
105
+ if (!root) continue
106
+ const entry = join(place.dir(root), 'main.mjs')
107
+ if (exists(entry)) return { entry }
108
+ missingHost ??= { pkg: place.pkg, entry }
109
+ }
110
+ return missingHost ? { missingHost } : { missingPackage: true }
111
+ }
112
+
113
+ /**
114
+ * How to start the app: the command, its arguments, the spawn options, and whether the
115
+ * launcher stays attached until it exits.
116
+ *
117
+ * - macOS goes through `open`, so LaunchServices handles the dock icon and single instance.
118
+ * - Linux runs the AppImage attached to the terminal, because its two usual first failures
119
+ * (missing FUSE, a missing system library) explain themselves on stderr and nowhere else.
120
+ * - Windows starts `centralu.exe` **detached**. That is not only about handing the prompt
121
+ * back: libuv puts every non-detached child in a job object that kills it when the parent
122
+ * exits, so an attached app would die the moment this launcher returns, or when the
123
+ * console window is closed. `centralu.exe` is a GUI-subsystem program (checked by
124
+ * `scripts/release-npm.mts`), so no console window appears for it either way.
125
+ * `windowsHide` stays off on purpose: it starts the process with SW_HIDE, which a GUI
126
+ * program applies to its first window — the app would start invisible.
127
+ * The working directory is the home folder, because a process holds its working
128
+ * directory open on Windows, and that would stop the shell's folder from being renamed
129
+ * or deleted (and `centralu install` from replacing the app's own folder).
130
+ */
131
+ export function launchPlan(platform, app, args, home) {
132
+ if (platform === 'darwin') {
133
+ return {
134
+ command: 'open',
135
+ args: ['-a', app, ...(args.length ? ['--args', ...args] : [])],
136
+ options: { stdio: 'inherit' },
137
+ attached: true,
138
+ }
139
+ }
140
+ if (platform === 'win32') {
141
+ return {
142
+ command: executableIn(platform, app),
143
+ args,
144
+ options: { detached: true, stdio: 'ignore', windowsHide: false, cwd: home },
145
+ attached: false,
146
+ }
147
+ }
148
+ return { command: app, args, options: { stdio: 'inherit' }, attached: true }
149
+ }
150
+
151
+ /**
152
+ * Where `centralu install` puts the app on Windows, and what it writes there.
153
+ *
154
+ * The analogue of copying the bundle into `/Applications` on macOS, not of the Linux menu
155
+ * entry that points back into the npm package. The reason is Windows-specific: a running
156
+ * program's files cannot be replaced, so while the app runs from inside the npm package,
157
+ * `npm i -g centralu@newer` fails with EBUSY. A copy elsewhere is what lets the package
158
+ * update while the app is open.
159
+ *
160
+ * `%LOCALAPPDATA%\Programs` is the per-user program folder (VS Code's user installer and
161
+ * winget's user scope use it). No admin rights, nothing outside the user's own profile.
162
+ * The Start menu entry is a shortcut in the per-user Start menu folder; Windows search
163
+ * indexes it.
164
+ *
165
+ * `versionFile` records which package version the copy came from, so the launcher can say
166
+ * when the copy is behind — the exe's own version resource does not carry a prerelease tag.
167
+ */
168
+ export function windowsInstall(env, home) {
169
+ const local = env.LOCALAPPDATA || win32.join(home, 'AppData', 'Local')
170
+ const roaming = env.APPDATA || win32.join(home, 'AppData', 'Roaming')
171
+ const dir = win32.join(local, 'Programs', APP_NAME)
172
+ return {
173
+ dir,
174
+ shortcut: win32.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', `${APP_NAME}.lnk`),
175
+ exe: win32.join(dir, 'centralu.exe'),
176
+ versionFile: win32.join(dir, 'centralu-version.txt'),
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Which of the names in `%LOCALAPPDATA%\Programs` are copies an earlier `centralu install` renamed
182
+ * aside (`Centralu.old-<time>`). Only those: the folder is shared with every other per-user
183
+ * program, and `Centralu` itself and a `Centralu.new` being assembled are not leftovers.
184
+ */
185
+ export function asideCopies(names) {
186
+ const prefix = `${APP_NAME}.old-`.toLowerCase()
187
+ return names.filter((n) => n.toLowerCase().startsWith(prefix) && /^\d+$/.test(n.slice(prefix.length)))
188
+ }
189
+
190
+ /**
191
+ * What `centralu install` creates, per platform. `uninstall` removes exactly these and
192
+ * `update` refreshes them when the first one exists.
193
+ */
194
+ export function installedPaths(platform, env, home) {
195
+ if (platform === 'darwin') return [`/Applications/${APP_NAME}.app`]
196
+ if (platform === 'win32') {
197
+ const w = windowsInstall(env, home)
198
+ return [w.dir, w.shortcut]
199
+ }
200
+ return [posix.join(home, '.local/share/applications/centralu.desktop')]
201
+ }
202
+
203
+ function systemRoot(env) {
204
+ return env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
205
+ }
206
+
207
+ /**
208
+ * A PowerShell invocation that writes the Start-menu shortcut.
209
+ *
210
+ * Windows has no file format for a shortcut that Node can write; `.lnk` is a binary shell
211
+ * format, and the supported way to write one is the WScript.Shell COM object. The paths go
212
+ * in through environment variables rather than the script text, so a profile path with a
213
+ * space or an apostrophe (`C:\Users\O'Brien Kim\…`) needs no quoting at all — the one way
214
+ * this cannot be got wrong. PowerShell is named by its full path under `%SystemRoot%` so a
215
+ * `powershell.exe` earlier on PATH (or in the current folder) is never the one that runs.
216
+ */
217
+ export function shortcutCommand(env, { shortcut, target, workdir }) {
218
+ const script = [
219
+ '$ErrorActionPreference = "Stop"',
220
+ 'New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:CENTRALU_SHORTCUT) | Out-Null',
221
+ '$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:CENTRALU_SHORTCUT)',
222
+ '$s.TargetPath = $env:CENTRALU_TARGET',
223
+ '$s.WorkingDirectory = $env:CENTRALU_WORKDIR',
224
+ '$s.IconLocation = "$env:CENTRALU_TARGET,0"',
225
+ `$s.Description = "${APP_NAME}"`,
226
+ '$s.Save()',
227
+ ].join('; ')
228
+ return {
229
+ command: win32.join(systemRoot(env), 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
230
+ args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
231
+ env: { ...env, CENTRALU_SHORTCUT: shortcut, CENTRALU_TARGET: target, CENTRALU_WORKDIR: workdir },
232
+ }
233
+ }
234
+
235
+ /**
236
+ * How to run npm from the launcher (`centralu update`).
237
+ *
238
+ * On Windows `npm` is `npm.cmd`, and Node refuses to start a `.cmd` without a shell
239
+ * (EINVAL, since the April 2024 security releases) — and without one it would not find it
240
+ * either, as libuv only tries `.com` and `.exe`. So Windows goes through the shell, and
241
+ * because a shell re-parses its command line, every argument has to be a plain token. The
242
+ * version comes from the registry's response, so that is checked rather than trusted.
243
+ */
244
+ export function npmCommand(platform, args) {
245
+ for (const a of args) {
246
+ if (!/^[\w@./:+-]+$/.test(a)) throw new Error(`refusing to pass an unexpected argument to npm: ${JSON.stringify(a)}`)
247
+ }
248
+ return platform === 'win32'
249
+ ? { command: 'npm', args, options: { stdio: 'inherit', shell: true } }
250
+ : { command: 'npm', args, options: { stdio: 'inherit' } }
251
+ }
252
+
253
+ export const WEBVIEW2_DOWNLOAD = 'https://developer.microsoft.com/microsoft-edge/webview2/'
254
+ /** Microsoft's link to the Evergreen Bootstrapper itself, for someone who wants one click. */
255
+ export const WEBVIEW2_BOOTSTRAPPER = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703'
256
+
257
+ /** The Evergreen WebView2 Runtime's client id, the same under every registry hive. */
258
+ const WEBVIEW2_CLIENT = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'
259
+
260
+ /**
261
+ * Where an installed WebView2 Runtime registers itself: per machine (the 32-bit view on
262
+ * 64-bit Windows, which is where the installer writes), per machine on 32-bit Windows, and
263
+ * per user. This is Microsoft's documented detection
264
+ * (learn.microsoft.com/microsoft-edge/webview2/concepts/distribution, "Detect if a WebView2
265
+ * Runtime is already installed").
266
+ */
267
+ export const WEBVIEW2_KEYS = [
268
+ `HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
269
+ `HKLM\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
270
+ `HKCU\\Software\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
271
+ ]
272
+
273
+ /** `reg.exe` by full path, for the same reason PowerShell is. */
274
+ export function regExe(env) {
275
+ return win32.join(systemRoot(env), 'System32', 'reg.exe')
276
+ }
277
+
278
+ /**
279
+ * The `pv` value out of `reg query <key> /v pv`, or null. Microsoft's rule: the runtime is
280
+ * installed when `pv` exists and is neither empty nor `0.0.0.0` (an uninstall can leave
281
+ * the key behind with that value).
282
+ */
283
+ export function parseRegPv(stdout) {
284
+ const m = /^\s*pv\s+REG_SZ\s+(\S*)\s*$/im.exec(String(stdout ?? ''))
285
+ const v = m?.[1] ?? ''
286
+ return v && v !== '0.0.0.0' ? v : null
287
+ }
288
+
289
+ /**
290
+ * Whether WebView2 is there, from a `query(key)` that returns `reg`'s stdout, `null` when
291
+ * the key does not exist, or `undefined` when `reg` itself could not run.
292
+ *
293
+ * Three answers, not two. "Missing" stops the launch with a message, so it has to be a
294
+ * positive finding — every hive was readable and none has the runtime. If `reg` could not
295
+ * run at all the answer is "unknown" and the app starts anyway: refusing to launch because
296
+ * a check broke would be worse than the blank window the check exists to explain.
297
+ *
298
+ * `WEBVIEW2_BROWSER_EXECUTABLE_FOLDER` points WebView2 at a fixed-version runtime that the
299
+ * registry knows nothing about; when it is set, the person has made that choice.
300
+ */
301
+ export function webview2Status(env, query) {
302
+ if (env.WEBVIEW2_BROWSER_EXECUTABLE_FOLDER) return { state: 'present', version: 'fixed' }
303
+ let unknown = false
304
+ for (const key of WEBVIEW2_KEYS) {
305
+ const out = query(key)
306
+ if (out === undefined) {
307
+ unknown = true
308
+ continue
309
+ }
310
+ const version = parseRegPv(out)
311
+ if (version) return { state: 'present', version }
312
+ }
313
+ return { state: unknown ? 'unknown' : 'missing' }
314
+ }
315
+
316
+ export function webview2MissingMessage() {
317
+ return (
318
+ `${APP_NAME} needs the Microsoft Edge WebView2 Runtime, and it is not installed on this machine.\n` +
319
+ 'Windows 11 includes it; some Windows 10 installs do not.\n' +
320
+ `Install the "Evergreen Bootstrapper" from ${WEBVIEW2_DOWNLOAD}\n` +
321
+ `(direct download: ${WEBVIEW2_BOOTSTRAPPER}),\n` +
322
+ 'then run `centralu` again.'
323
+ )
324
+ }
325
+
326
+ /**
327
+ * What to say when the exe exits nonzero within moments of starting.
328
+ *
329
+ * The launcher is detached by then, so this is the only place a failure to start can be
330
+ * reported at all — a GUI program has no stderr to read. The registry check catches the
331
+ * usual cause before the start; this catches the rest (a runtime that is registered but
332
+ * broken, a blocked exe, a crash before the window).
333
+ */
334
+ export function earlyExitMessage(code, home) {
335
+ return (
336
+ `${APP_NAME} exited right after starting (exit code ${code}).\n` +
337
+ `- If no window appeared, check the WebView2 Runtime: ${WEBVIEW2_DOWNLOAD}\n` +
338
+ `- The host log is ${win32.join(home, '.centralu', 'host.log')}\n` +
339
+ 'Please report it at https://github.com/ijun17/centralu/issues/14 with that log.'
340
+ )
341
+ }
342
+
343
+ /**
344
+ * What to say when Windows refuses to replace or remove the app's files.
345
+ *
346
+ * EBUSY / EPERM on a program folder almost always means the program is running: Windows
347
+ * locks a running exe and the native modules the host loaded. Macs and Linux let both be
348
+ * replaced underneath a running process, which is why this message exists only here.
349
+ */
350
+ export function busyMessage(path) {
351
+ return (
352
+ `Windows would not replace ${path} — ${APP_NAME} is probably still running.\n` +
353
+ 'Quit it (close the window and choose Quit), then run the command again.'
354
+ )
355
+ }
356
+
357
+ export function isBusyError(e) {
358
+ return e?.code === 'EBUSY' || e?.code === 'EPERM' || e?.code === 'EACCES' || e?.code === 'ENOTEMPTY'
359
+ }
package/bin/semver.mjs ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Version comparison for the launcher's update check.
3
+ *
4
+ * Lives in its own file so it can be tested (tooling/launcher-semver.test.ts):
5
+ * the launcher itself runs commands at import time and cannot be imported by a
6
+ * test. package.json "files" lists the whole bin/ directory, so this ships
7
+ * automatically — but note npm silently drops anything "files" does not cover,
8
+ * and an unshipped import only crashes on users' machines.
9
+ *
10
+ * Why not `.split('.').map(Number)`: that turns `0.1.0-beta.2` into
11
+ * [0, 1, NaN, 2], and `NaN !== NaN` ends the comparison at the third slot with
12
+ * `NaN > NaN` = false — so no beta ever saw the next beta. That is exactly how
13
+ * `centralu update` on 0.1.0-beta.1 answered "Already up to date" while
14
+ * beta.2 sat on the registry (found the day beta.2 shipped).
15
+ */
16
+ export function isNewer(a, b) {
17
+ const [coreA, preA] = split(String(a))
18
+ const [coreB, preB] = split(String(b))
19
+ for (let i = 0; i < 3; i++) {
20
+ const x = coreA[i] ?? 0
21
+ const y = coreB[i] ?? 0
22
+ if (x !== y) return x > y
23
+ }
24
+ // Same core: a release outranks any prerelease of it (1.0.0 > 1.0.0-beta.9).
25
+ if (preA === null && preB === null) return false
26
+ if (preA === null) return true
27
+ if (preB === null) return false
28
+ // Both prereleases: compare dot-separated identifiers, numeric ones as
29
+ // numbers (semver: numeric identifiers always rank below alphanumeric).
30
+ const ia = preA.split('.')
31
+ const ib = preB.split('.')
32
+ for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
33
+ const x = ia[i]
34
+ const y = ib[i]
35
+ if (x === undefined) return false // shorter prerelease ranks lower
36
+ if (y === undefined) return true
37
+ const nx = /^\d+$/.test(x) ? Number(x) : null
38
+ const ny = /^\d+$/.test(y) ? Number(y) : null
39
+ if (nx !== null && ny !== null) {
40
+ if (nx !== ny) return nx > ny
41
+ } else if (nx !== null) {
42
+ return false // numeric < alphanumeric
43
+ } else if (ny !== null) {
44
+ return true
45
+ } else if (x !== y) {
46
+ return x > y
47
+ }
48
+ }
49
+ return false
50
+ }
51
+
52
+ /**
53
+ * Whether the copy in /Applications is a different app than the one this package carries.
54
+ *
55
+ * `npm i -g centralu` replaces the package and nothing else. That is deliberate — this
56
+ * project does not write into someone's /Applications unless asked (`centralu install`) —
57
+ * but the cost is that the two drift apart in silence, and the copy is the one people
58
+ * actually click. So the launcher says so. It still does not act.
59
+ *
60
+ * Not `isNewer`. Drift in either direction is worth a line: a copy *newer* than the package
61
+ * means an update went backwards, which is more surprising, not less.
62
+ *
63
+ * Lives beside `isNewer` because it ships and is tested for the same reason — a launcher
64
+ * already on someone's machine cannot be corrected later. 0.1.0-beta.1 is still out there
65
+ * answering "Already up to date" and always will be.
66
+ */
67
+ export function copyDiffers(pkgVersion, copyVersion) {
68
+ return typeof copyVersion === 'string' && copyVersion !== '' && copyVersion !== pkgVersion
69
+ }
70
+
71
+ /*
72
+ * Build metadata (`+sha`) is dropped first: semver ignores it for precedence, and left in, it turned `3+build5`
73
+ * into NaN (and the `-` in `+build-5` into a prerelease). A leading `v` (`v1.2.3`, as a tag is written) is dropped
74
+ * too, or the first slot is NaN. Either NaN ends the comparison as "not newer", the #42 failure again.
75
+ */
76
+ function split(raw) {
77
+ let v = raw.trim().replace(/^v/i, '')
78
+ const plus = v.indexOf('+')
79
+ if (plus !== -1) v = v.slice(0, plus)
80
+ const dash = v.indexOf('-')
81
+ const core = (dash === -1 ? v : v.slice(0, dash)).split('.').map(Number)
82
+ const pre = dash === -1 ? null : v.slice(dash + 1)
83
+ return [core, pre]
84
+ }