@huynle/hub 0.8.4 → 0.8.6

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 (96) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +40 -3
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +5288 -4415
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
  8. package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
  9. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
  10. package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
  11. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
  12. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
  13. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
  14. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
  15. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
  16. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
  17. package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
  18. package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
  19. package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
  20. package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
  21. package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
  22. package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
  23. package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
  24. package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
  25. package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
  26. package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
  27. package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
  28. package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
  29. package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
  30. package/packages/cli/src/bootstrap/release/README.md +63 -9
  31. package/packages/cli/src/bootstrap/release/cli.ts +94 -16
  32. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
  33. package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
  34. package/packages/cli/src/bootstrap/release/pins.ts +60 -9
  35. package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
  36. package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
  37. package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
  38. package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
  39. package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
  40. package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
  41. package/packages/cli/src/credentials.ts +5 -2
  42. package/packages/cli/src/main.ts +3 -2
  43. package/packages/cli/src/setup/commands.ts +96 -18
  44. package/packages/cli/src/setup/contributors.ts +45 -10
  45. package/packages/cli/src/setup/runtime.ts +25 -6
  46. package/packages/cli/src/setup/self-aside.ts +130 -0
  47. package/packages/codex/package.json +1 -1
  48. package/packages/node/package.json +1 -1
  49. package/packages/node/src/opencode-warm.ts +12 -4
  50. package/packages/preset/package.json +1 -1
  51. package/packages/protocol/package.json +1 -1
  52. package/packages/runtime-openshell/package.json +1 -1
  53. package/packages/runtime-sdk/package.json +1 -1
  54. package/packages/sdk/package.json +1 -1
  55. package/packages/spoke-sdk/package.json +1 -1
  56. package/src/central/config.ts +12 -0
  57. package/src/central/http.ts +10 -0
  58. package/src/central/install/CONTRACT.md +66 -0
  59. package/src/central/install/install.ps1 +349 -0
  60. package/src/central/install/install.sh +266 -0
  61. package/src/central/install/render.ts +146 -0
  62. package/src/central/install/templates.ts +6 -0
  63. package/src/central/install/text-assets.d.ts +9 -0
  64. package/src/config.ts +12 -7
  65. package/src/spoke/env-inject.ts +17 -2
  66. package/src/spoke/herdr.ts +3 -2
  67. package/src/spoke/launchers.ts +145 -9
  68. package/src/spoke/localhub.ts +1 -0
  69. package/src/win-acl.ts +154 -0
  70. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
  71. package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
  72. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
  73. package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
  74. package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
  75. package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
  76. package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
  77. package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
  78. package/web/dist/assets/hub-CrbItiwP.js +13 -0
  79. package/web/dist/assets/index-82Ce0IAf.css +1 -0
  80. package/web/dist/assets/index-D9SP3_0z.js +22 -0
  81. package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
  82. package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
  83. package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
  84. package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
  85. package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
  86. package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
  87. package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
  88. package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
  89. package/web/dist/index.html +6 -4
  90. package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
  91. package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
  92. package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
  93. package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
  94. package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
  95. package/web/dist/assets/index-DVSkeAvn.css +0 -1
  96. package/web/dist/assets/index-DifgVuKS.js +0 -34
@@ -13,8 +13,16 @@
13
13
  // when nothing is there or it is hub's own link, and never when another `hub` is on PATH. ~/.local/bin/hubctl is left
14
14
  // to `hubctl setup`: it is the standalone hubctl's install path (a link there would make that install refuse to write
15
15
  // through it, and the launchers would exec themselves).
16
+ //
17
+ // On Windows (Phase 2) there are no sh scripts and no symlinks (they need Developer Mode or admin): the hub bin dir
18
+ // gets hub.cmd and hubctl.cmd, which run ~\.local\bin\hubctl.exe when it is installed (decided at plugin start)
19
+ // and the bundle otherwise, with BUN_BE_BUN set only inside the launcher (setlocal). ~\.local\bin\hub.cmd is a cmd
20
+ // shim (packages/cli/src/bootstrap/transaction/shim.ts) to the bin dir's hub.cmd, under the same user-bin rule;
21
+ // hubctl's own hub.cmd (a shim to hubctl.exe, link-hub) is the authority and is never touched. Older plugin versions
22
+ // left POSIX launchers and a ~/.local/bin/hub symlink there; being hub's own, they are removed.
16
23
  import { accessSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"
17
24
  import path from "node:path"
25
+ import { cmdShim, cmdWord, parseCmdShim } from "../../packages/cli/src/bootstrap/transaction/shim.ts"
18
26
  import { userBinLinkDecision, type UserBinEntry } from "../../packages/cli/src/setup/user-bin.ts"
19
27
 
20
28
  export const LAUNCHERS = ["hub", "hubctl"] as const
@@ -31,19 +39,35 @@ export interface LauncherOptions {
31
39
  readonly bun: boolean
32
40
  /** The CLI entry the runtime runs. */
33
41
  readonly entry: string
34
- /** The standalone hubctl to prefer when it exists (standaloneHubctl(home)). */
42
+ /**
43
+ * The standalone hubctl to prefer when it exists (standaloneHubctl(home)). POSIX: checked when the launcher runs;
44
+ * win32: installLaunchers passes it only when it exists, and launcherScript then runs it unconditionally.
45
+ */
35
46
  readonly standalone?: string
47
+ /** The platform (default process.platform): "win32" writes cmd launchers. */
48
+ readonly platform?: string
36
49
  }
37
50
 
38
- /** Where `hubctl setup` installs the standalone hubctl (contract user-bin:hubctl). */
39
- export const standaloneHubctl = (home: string): string => path.join(home, ".local", "bin", "hubctl")
51
+ const isWin = (platform: string | undefined) => (platform ?? process.platform) === "win32"
52
+
53
+ /** Where `hubctl setup` installs the standalone hubctl (contract user-bin:hubctl, user-bin:hubctl.exe on Windows). */
54
+ export const standaloneHubctl = (home: string, platform: string = process.platform): string => path.join(home, ".local", "bin", platform === "win32" ? "hubctl.exe" : "hubctl")
40
55
 
41
56
  /** A word quoted for sh. */
42
57
  const quote = (word: string) => `'${word.replaceAll("'", `'\\''`)}'`
43
58
 
44
59
  const LAUNCHER_HEADER = "#!/bin/sh\n# hub CLI launcher"
60
+ const CMD_HEADER = "@rem hub CLI launcher"
61
+
62
+ /** win32: a cmd file (CRLF, ASCII; every path one quoted word, see cmdWord). */
63
+ function cmdLauncher({ execPath, bun, entry, standalone }: LauncherOptions): string {
64
+ const lines = standalone ? [`@${cmdWord(standalone)} %*`] : bun ? ["@setlocal", "@set BUN_BE_BUN=1", `@${cmdWord(execPath)} ${cmdWord(entry)} %*`] : [`@${cmdWord(execPath)} ${cmdWord(entry)} %*`]
65
+ return [`${CMD_HEADER}, written by the hub OpenCode plugin at start (edits are overwritten).`, ...lines, ""].join("\r\n")
66
+ }
45
67
 
46
- export function launcherScript({ execPath, bun, entry, standalone }: LauncherOptions): string {
68
+ export function launcherScript(options: LauncherOptions): string {
69
+ if (isWin(options.platform)) return cmdLauncher(options)
70
+ const { execPath, bun, entry, standalone } = options
47
71
  const command = bun ? `exec env BUN_BE_BUN=1 ${quote(execPath)} ${quote(entry)} "$@"` : `exec node ${quote(entry)} "$@"`
48
72
  const prefer = standalone ? `if [ -x ${quote(standalone)} ]; then exec ${quote(standalone)} "$@"; fi\n` : ""
49
73
  return `${LAUNCHER_HEADER}, written by the hub OpenCode plugin at start (edits are overwritten).\n${prefer}${command}\n`
@@ -62,6 +86,7 @@ export function cliEntry(root: string, bun: boolean): string | undefined {
62
86
  * reads half a file). Returns the names written.
63
87
  */
64
88
  export function installLaunchers(options: LauncherOptions & { readonly binDir: string }): { written: string[] } {
89
+ if (isWin(options.platform)) return installCmdLaunchers(options)
65
90
  const script = launcherScript(options)
66
91
  mkdirSync(options.binDir, { recursive: true, mode: 0o700 })
67
92
  const written: string[] = []
@@ -86,6 +111,34 @@ export function installLaunchers(options: LauncherOptions & { readonly binDir: s
86
111
  return { written }
87
112
  }
88
113
 
114
+ /** win32: hub.cmd and hubctl.cmd (no modes); an older plugin's POSIX launchers there are removed. */
115
+ function installCmdLaunchers(options: LauncherOptions & { readonly binDir: string }): { written: string[] } {
116
+ const { standalone, ...rest } = options
117
+ const script = launcherScript({ ...rest, ...(standalone && existsSync(standalone) ? { standalone } : {}) })
118
+ mkdirSync(options.binDir, { recursive: true, mode: 0o700 })
119
+ const written: string[] = []
120
+ for (const base of LAUNCHERS) {
121
+ const stale = path.join(options.binDir, base)
122
+ if (entryOf(stale) === "file" && isLauncher(stale)) rmSync(stale, { force: true })
123
+ const name = `${base}.cmd`
124
+ const file = path.join(options.binDir, name)
125
+ if (readText(file) === script) continue
126
+ const tmp = `${file}.${process.pid}.tmp`
127
+ writeFileSync(tmp, script)
128
+ renameSync(tmp, file)
129
+ written.push(name)
130
+ }
131
+ return { written }
132
+ }
133
+
134
+ const readText = (file: string): string | undefined => {
135
+ try {
136
+ return readFileSync(file, "latin1")
137
+ } catch {
138
+ return undefined
139
+ }
140
+ }
141
+
89
142
  /** The user's own bin dir (contract root user-bin). */
90
143
  export const userBinDir = (home: string): string => path.join(home, ".local", "bin")
91
144
 
@@ -98,13 +151,13 @@ function entryOf(file: string): UserBinEntry {
98
151
  }
99
152
  }
100
153
 
101
- /** A file that starts like the launchers hub writes. */
102
- function isLauncher(file: string): boolean {
154
+ /** A file that starts like the launchers hub writes (the cmd ones on win32). */
155
+ function isLauncher(file: string, header: string = LAUNCHER_HEADER): boolean {
103
156
  let fd: number | undefined
104
157
  try {
105
158
  fd = openSync(file, "r")
106
- const buffer = Buffer.alloc(LAUNCHER_HEADER.length)
107
- return readSync(fd, buffer, 0, buffer.length, 0) === buffer.length && buffer.toString("utf8") === LAUNCHER_HEADER
159
+ const buffer = Buffer.alloc(header.length)
160
+ return readSync(fd, buffer, 0, buffer.length, 0) === buffer.length && buffer.toString("utf8") === header
108
161
  } catch {
109
162
  return false
110
163
  } finally {
@@ -126,17 +179,100 @@ function onPath(name: string, searchPath: string, skip: string): string | undefi
126
179
  return undefined
127
180
  }
128
181
 
182
+ /** win32: PATH entries compare without case, separator style or a trailing separator. */
183
+ const winKey = (dir: string) => dir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
184
+
185
+ /** win32: the first `name` with a PATHEXT extension on the ';'-separated `searchPath`, outside `skip`. */
186
+ function onWinPath(name: string, searchPath: string, skip: string): string | undefined {
187
+ const exts = [".com", ".exe", ".bat", ".cmd"]
188
+ for (const dir of searchPath.split(";")) {
189
+ if (!dir || winKey(dir) === winKey(skip)) continue
190
+ for (const ext of exts) {
191
+ const file = path.join(dir, `${name}${ext}`)
192
+ try {
193
+ if (statSync(file).isFile()) return file
194
+ } catch {}
195
+ }
196
+ }
197
+ return undefined
198
+ }
199
+
200
+ /** win32: what a cmd file is, read as the user-bin rule sees links: a hub cmd shim is a link to its target. */
201
+ function cmdEntryOf(file: string): { entry: UserBinEntry; pointsTo?: string } {
202
+ const entry = entryOf(file)
203
+ if (entry !== "file") return { entry }
204
+ let bytes: Uint8Array
205
+ try {
206
+ bytes = readFileSync(file)
207
+ } catch {
208
+ return { entry }
209
+ }
210
+ const target = parseCmdShim(bytes)
211
+ return target === undefined ? { entry } : { entry: "symlink", pointsTo: target }
212
+ }
213
+
214
+ /** win32: linkUserBin with cmd shims (see the header). */
215
+ function linkUserBinCmd(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined }): { linked: string[]; hint?: string } {
216
+ const userBin = userBinDir(options.home)
217
+ // An older plugin's symlink ~/.local/bin/hub → its POSIX launcher: hub's own, and unusable here.
218
+ const old = path.join(userBin, "hub")
219
+ if (entryOf(old) === "symlink") {
220
+ let to: string | undefined
221
+ try {
222
+ to = path.resolve(userBin, readlinkSync(old))
223
+ } catch {}
224
+ if (to !== undefined && (winKey(to) === winKey(path.join(options.binDir, "hub")) || isLauncher(to))) rmSync(old, { force: true })
225
+ }
226
+ const linkPath = path.join(userBin, "hub.cmd")
227
+ const target = path.join(options.binDir, "hub.cmd")
228
+ const { entry, pointsTo } = cmdEntryOf(linkPath)
229
+ const searchPath = options.path ?? ""
230
+ const onUserPath = searchPath.split(";").some((dir) => dir && winKey(dir) === winKey(userBin))
231
+ const pathHint = onUserPath ? undefined : `${userBin} is not on PATH: add it to your user PATH to use \`hub\` in your terminal`
232
+ // hubctl's link-hub shim to the standalone hubctl.exe: the authority, keep it.
233
+ if (pointsTo !== undefined && winKey(pointsTo) === winKey(standaloneHubctl(options.home, "win32"))) return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
234
+ const foundOnPath = onWinPath("hub", searchPath, options.binDir)
235
+ const decision = userBinLinkDecision({
236
+ entry,
237
+ ...(pointsTo !== undefined ? { pointsTo: winKey(pointsTo) === winKey(target) ? target : pointsTo } : {}),
238
+ target,
239
+ linkPath,
240
+ hubOwned: (p) => isLauncher(p, CMD_HEADER),
241
+ ...(foundOnPath !== undefined && winKey(foundOnPath) !== winKey(linkPath) ? { foundOnPath } : {}),
242
+ })
243
+ if (decision === "foreign") return { linked: [] }
244
+ if (decision === "shadowed") return { linked: [], hint: `\`hub\` on your PATH is ${foundOnPath}, not hub's CLI; hub's is ${target}` }
245
+ if (decision === "in-place") return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
246
+ mkdirSync(userBin, { recursive: true })
247
+ const shim = cmdShim(target)
248
+ // A new shim never replaces what may have appeared meanwhile ("wx"); hub's own is replaced atomically.
249
+ if (decision === "create") writeFileSync(linkPath, shim, { flag: "wx" })
250
+ else {
251
+ const tmp = `${linkPath}.${process.pid}.tmp`
252
+ rmSync(tmp, { force: true })
253
+ writeFileSync(tmp, shim)
254
+ try {
255
+ renameSync(tmp, linkPath)
256
+ } catch (cause) {
257
+ rmSync(tmp, { force: true })
258
+ throw cause
259
+ }
260
+ }
261
+ return { linked: ["hub.cmd"], ...(pathHint ? { hint: pathHint } : {}) }
262
+ }
263
+
129
264
  /**
130
265
  * Links ~/.local/bin/hub to `binDir`/hub (see the header). Returns what it linked, and one hint when the user's
131
266
  * terminal will not find it (~/.local/bin not on `path`, or another `hub` comes first). Small synchronous calls,
132
267
  * nothing when converged (the plugin runs this at start).
133
268
  */
134
- export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined; readonly defaultBinDir?: string | undefined }): { linked: string[]; hint?: string } {
269
+ export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined; readonly defaultBinDir?: string | undefined; readonly platform?: string }): { linked: string[]; hint?: string } {
135
270
  // Only the default hub bin dir (env-inject.ts defaultHubBinDir: the resolved state dir's bin, default
136
271
  // ~/.local/state/hub/bin): a custom HUB_STATE_DIR (tests, containers, a throwaway state) must never leave
137
272
  // ~/.local/bin pointing into it.
138
273
  const allowed = options.defaultBinDir ?? path.join(options.home, ".local", "state", "hub", "bin")
139
274
  if (path.resolve(options.binDir) !== path.resolve(allowed)) return { linked: [] }
275
+ if (isWin(options.platform)) return linkUserBinCmd(options)
140
276
  const userBin = userBinDir(options.home)
141
277
  const linkPath = path.join(userBin, "hub")
142
278
  const target = path.join(options.binDir, "hub")
@@ -213,6 +213,7 @@ export async function ensureLocalHub(server: string, options: LocalHubOptions =
213
213
  child = (options.spawn ?? (nodeSpawn as unknown as SpawnLike))(runtime.command, [hubScript(root)], {
214
214
  cwd: root,
215
215
  detached: true,
216
+ windowsHide: true,
216
217
  stdio: ["ignore", log, log],
217
218
  env: childEnv,
218
219
  })
package/src/win-acl.ts ADDED
@@ -0,0 +1,154 @@
1
+ // Owner-only files on Windows (Phase 2 contract "Secrets on win32"). Windows has no POSIX modes: a file that is 0600
2
+ // on macOS/Linux (config.json with the machine token, cli.json, a saved setup preview) gets an ACL instead. It is
3
+ // created empty, restricted with `icacls <file> /inheritance:r /grant:r *<current user SID>:(F)` before the secret
4
+ // is written, then moved into place (a rename on the same volume keeps the ACL). Checks that refuse `mode & 0o077`
5
+ // on POSIX verify on Windows that the DACL allows no one but the current user, SYSTEM and Administrators.
6
+ //
7
+ // Shared by src/ (config.json), packages/cli (cli.json, the credential store, previews) and packages/node (the
8
+ // password-file check); it lives in src/ because both of the others already import from it. No native addons (it
9
+ // runs inside a Bun-compiled hubctl.exe): icacls and whoami are run by absolute path under %SystemRoot%, and tests
10
+ // inject the runner.
11
+ import { spawnSync } from "node:child_process"
12
+ import { randomBytes } from "node:crypto"
13
+ import { closeSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs"
14
+ import { tmpdir } from "node:os"
15
+ import path from "node:path"
16
+
17
+ /** Runs icacls or whoami with exactly these arguments (no shell). */
18
+ export type WinAclRunner = (program: "icacls" | "whoami", args: readonly string[]) => { status: number | null; stdout: string; stderr: string }
19
+
20
+ export interface WinAcl {
21
+ /** The current user's SID (resolved once). */
22
+ sid(): string
23
+ /** Removes inherited entries and grants only the current user full control. Throws when icacls fails. */
24
+ restrictToOwner(nativePath: string): void
25
+ /** Whether the file's DACL allows only the current user, SYSTEM and Administrators. */
26
+ check(nativePath: string): { ok: true } | { ok: false; problem: string }
27
+ }
28
+
29
+ export interface WinAclOptions {
30
+ readonly run?: WinAclRunner
31
+ /** The current user's SID without a subprocess (tests, or a caller that already knows it). */
32
+ readonly sid?: () => string
33
+ readonly env?: Readonly<Record<string, string | undefined>>
34
+ }
35
+
36
+ const SYSTEM32 = (env: Readonly<Record<string, string | undefined>>) => `${env.SystemRoot || env.SYSTEMROOT || "C:\\Windows"}\\System32`
37
+
38
+ function systemRunner(env: Readonly<Record<string, string | undefined>>): WinAclRunner {
39
+ return (program, args) => {
40
+ const r = spawnSync(`${SYSTEM32(env)}\\${program}.exe`, [...args], { encoding: "utf8", windowsHide: true, timeout: 30_000 })
41
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? (r.error ? String(r.error.message) : "") }
42
+ }
43
+ }
44
+
45
+ const SID = /^S-1-[0-9]+(-[0-9]+)+$/
46
+
47
+ /** The SID in `whoami /user /fo csv /nh` output (`"host\user","S-1-5-21-…"`). */
48
+ export function parseWhoamiSid(stdout: string): string | undefined {
49
+ const fields = [...stdout.matchAll(/"([^"]*)"/g)].map((m) => m[1]!)
50
+ const sid = fields.at(-1)
51
+ return sid && SID.test(sid) ? sid : undefined
52
+ }
53
+
54
+ /** SYSTEM and Administrators, by SDDL alias and by SID. */
55
+ const TRUSTED = new Set(["SY", "S-1-5-18", "BA", "S-1-5-32-544"])
56
+ const ALLOW = new Set(["A", "OA", "XA", "ZA"])
57
+
58
+ /**
59
+ * Why a DACL (SDDL, as `icacls /save` writes it) is not owner-only, or undefined when it allows no one but `sid`,
60
+ * SYSTEM and Administrators. Deny entries and inherit-only entries (which do not apply to the file) are ignored.
61
+ */
62
+ export function sddlProblem(sddl: string, sid: string): string | undefined {
63
+ const dacl = /D:([A-Z]*)((?:\([^)]*\))*)/.exec(sddl)
64
+ if (!dacl || /D:NO_ACCESS_CONTROL/.test(sddl)) return "it has no access control list (no access control: everyone may read it)"
65
+ const others: string[] = []
66
+ for (const ace of dacl[2]!.matchAll(/\(([^)]*)\)/g)) {
67
+ const [type, flags = "", , , , who = ""] = ace[1]!.split(";")
68
+ if (!ALLOW.has(type!) || /IO/.test(flags)) continue
69
+ if (who === sid || TRUSTED.has(who)) continue
70
+ others.push(who)
71
+ }
72
+ return others.length ? `its access list allows users other than you, SYSTEM and Administrators (${[...new Set(others)].join(", ")})` : undefined
73
+ }
74
+
75
+ export function winAcl(options: WinAclOptions = {}): WinAcl {
76
+ const run = options.run ?? systemRunner(options.env ?? process.env)
77
+ let sid: string | undefined
78
+ const self: WinAcl = {
79
+ sid() {
80
+ if (sid) return sid
81
+ if (options.sid) return (sid = options.sid())
82
+ const r = run("whoami", ["/user", "/fo", "csv", "/nh"])
83
+ const found = r.status === 0 ? parseWhoamiSid(r.stdout) : undefined
84
+ if (!found) throw new Error(`cannot determine the current Windows user (whoami exited ${r.status})`)
85
+ return (sid = found)
86
+ },
87
+ restrictToOwner(nativePath) {
88
+ const r = run("icacls", [nativePath, "/inheritance:r", "/grant:r", `*${self.sid()}:(F)`])
89
+ if (r.status !== 0) throw new Error(`cannot restrict ${nativePath} to the current user (icacls exited ${r.status}${r.stderr.trim() ? `: ${r.stderr.trim()}` : ""})`)
90
+ },
91
+ check(nativePath) {
92
+ const dir = mkdtempSync(path.join(tmpdir(), "hub-acl-"))
93
+ try {
94
+ const saved = path.join(dir, "acl.txt")
95
+ const r = run("icacls", [nativePath, "/save", saved])
96
+ let text: string
97
+ try {
98
+ text = r.status === 0 ? readFileSync(saved).toString("utf16le") : ""
99
+ } catch {
100
+ text = ""
101
+ }
102
+ if (!text) return { ok: false, problem: `cannot read the access list of ${nativePath} (icacls exited ${r.status})` }
103
+ const problem = sddlProblem(text, self.sid())
104
+ return problem ? { ok: false, problem } : { ok: true }
105
+ } catch (cause) {
106
+ return { ok: false, problem: `cannot read the access list of ${nativePath}: ${(cause as Error).message}` }
107
+ } finally {
108
+ rmSync(dir, { recursive: true, force: true })
109
+ }
110
+ },
111
+ }
112
+ return self
113
+ }
114
+
115
+ /** Where a secret file is written: the platform (default process.platform) and, on win32, the ACL helper. */
116
+ export interface SecretFileOptions {
117
+ readonly platform?: string
118
+ readonly acl?: WinAcl
119
+ }
120
+
121
+ let shared: WinAcl | undefined
122
+ /** The process-wide helper (the SID is resolved once). */
123
+ export const defaultWinAcl = (): WinAcl => (shared ??= winAcl())
124
+
125
+ /** Whether `options` (default: this process) are for Windows. */
126
+ export const isWin32 = (options?: SecretFileOptions): boolean => (options?.platform ?? process.platform) === "win32"
127
+
128
+ /** The Windows form of a drive path for icacls ("C:/a/b" → "C:\a\b"); anything else as it is. */
129
+ export const nativeWinPath = (p: string): string => (/^[A-Za-z]:[\\/]/.test(p) ? p.replace(/\//g, "\\") : p)
130
+
131
+ /**
132
+ * Writes `content` to `file` owner-only on Windows: a temporary file next to it is created empty, restricted to the
133
+ * current user, written and fsynced, then renamed over `file` (the ACL moves with it). A failure removes the
134
+ * temporary file and leaves `file` as it was.
135
+ */
136
+ export function writeOwnerOnlyFile(file: string, content: string, acl: WinAcl): void {
137
+ mkdirSync(path.dirname(file), { recursive: true })
138
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
139
+ let fd: number | undefined
140
+ try {
141
+ closeSync(openSync(tmp, "wx"))
142
+ acl.restrictToOwner(nativeWinPath(tmp))
143
+ fd = openSync(tmp, "r+")
144
+ writeSync(fd, content)
145
+ fsyncSync(fd)
146
+ closeSync(fd)
147
+ fd = undefined
148
+ renameSync(tmp, file)
149
+ } catch (cause) {
150
+ if (fd !== undefined) closeSync(fd)
151
+ rmSync(tmp, { force: true })
152
+ throw cause
153
+ }
154
+ }
@@ -0,0 +1 @@
1
+ import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-j5FFk90B.js";import{r as o}from"./preload-helper-DqrJTq0t.js";import{L as s,R as c}from"./index-D9SP3_0z.js";import{t as l}from"./ArtifactWorkbench-vrgIfncX.js";var u=a(`<div class="feature-page av-page">`),d=a(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(a){return(()=>{var f=u();return n(f,e(l,{get id(){return a.id},layout:`page`,get lead(){return[e(t,{get when(){return s()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:c})}}),(()=>{var t=d();return n(t,e(i,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>o(a.id)})}})),f})()}export{f as default};
@@ -1 +1 @@
1
- import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-Qo2Pd9-O.js";import{t as o}from"./ArtifactWorkbench-C37tZZ3e.js";var s=a(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(a){return e(o,{get id(){return a.artifactID},layout:`pane`,onArtifact:e=>a.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(i,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(a.artifactID)}`)),o})()},get trailing(){return a.paneTools}})}export{c as ArtifactPane};
1
+ import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-j5FFk90B.js";import{t as o}from"./ArtifactWorkbench-vrgIfncX.js";var s=a(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(a){return e(o,{get id(){return a.artifactID},layout:`pane`,onArtifact:e=>a.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(i,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(a.artifactID)}`)),o})()},get trailing(){return a.paneTools}})}export{c as ArtifactPane};