@huynle/hub 0.8.4 → 0.8.5
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/CHANGELOG.md +42 -0
- package/README.md +40 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +5288 -4415
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
- package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
- package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
- package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
- package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
- package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
- package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
- package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
- package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
- package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
- package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
- package/packages/cli/src/bootstrap/release/README.md +63 -9
- package/packages/cli/src/bootstrap/release/cli.ts +94 -16
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
- package/packages/cli/src/bootstrap/release/pins.ts +60 -9
- package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
- package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
- package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
- package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
- package/packages/cli/src/credentials.ts +5 -2
- package/packages/cli/src/main.ts +3 -2
- package/packages/cli/src/setup/commands.ts +96 -18
- package/packages/cli/src/setup/contributors.ts +45 -10
- package/packages/cli/src/setup/runtime.ts +25 -6
- package/packages/cli/src/setup/self-aside.ts +130 -0
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/opencode-warm.ts +12 -4
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/config.ts +12 -0
- package/src/central/http.ts +10 -0
- package/src/central/install/CONTRACT.md +66 -0
- package/src/central/install/install.ps1 +349 -0
- package/src/central/install/install.sh +266 -0
- package/src/central/install/render.ts +146 -0
- package/src/central/install/templates.ts +6 -0
- package/src/central/install/text-assets.d.ts +9 -0
- package/src/config.ts +12 -7
- package/src/spoke/env-inject.ts +17 -2
- package/src/spoke/launchers.ts +145 -9
- package/src/win-acl.ts +154 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
- package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
- package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
- package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
- package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
- package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
- package/web/dist/assets/hub-CrbItiwP.js +13 -0
- package/web/dist/assets/index-82Ce0IAf.css +1 -0
- package/web/dist/assets/index-D9SP3_0z.js +22 -0
- package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
- package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
- package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
- package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
- package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
- package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
- package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
- package/web/dist/index.html +6 -4
- package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
- package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
- package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
- package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
- package/web/dist/assets/index-DVSkeAvn.css +0 -1
- package/web/dist/assets/index-DifgVuKS.js +0 -34
package/src/spoke/launchers.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
39
|
-
|
|
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(
|
|
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(
|
|
107
|
-
return readSync(fd, buffer, 0, buffer.length, 0) === buffer.length && buffer.toString("utf8") ===
|
|
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")
|
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-
|
|
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};
|