@kimchi-dev/kimchictl 0.0.2
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/LICENSE +203 -0
- package/README.md +105 -0
- package/bin/kimchictl.mjs +17 -0
- package/dist/api/http.d.ts +7 -0
- package/dist/api/http.d.ts.map +1 -0
- package/dist/api/http.js +60 -0
- package/dist/api/http.js.map +1 -0
- package/dist/api/keys.d.ts +7 -0
- package/dist/api/keys.d.ts.map +1 -0
- package/dist/api/keys.js +28 -0
- package/dist/api/keys.js.map +1 -0
- package/dist/api/me.d.ts +12 -0
- package/dist/api/me.d.ts.map +1 -0
- package/dist/api/me.js +28 -0
- package/dist/api/me.js.map +1 -0
- package/dist/api/resolver.d.ts +26 -0
- package/dist/api/resolver.d.ts.map +1 -0
- package/dist/api/resolver.js +62 -0
- package/dist/api/resolver.js.map +1 -0
- package/dist/api/resources.d.ts +16 -0
- package/dist/api/resources.d.ts.map +1 -0
- package/dist/api/resources.js +96 -0
- package/dist/api/resources.js.map +1 -0
- package/dist/api/templates.d.ts +19 -0
- package/dist/api/templates.d.ts.map +1 -0
- package/dist/api/templates.js +25 -0
- package/dist/api/templates.js.map +1 -0
- package/dist/api/tokens.d.ts +23 -0
- package/dist/api/tokens.d.ts.map +1 -0
- package/dist/api/tokens.js +51 -0
- package/dist/api/tokens.js.map +1 -0
- package/dist/api/types.d.ts +34 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +32 -0
- package/dist/api/types.js.map +1 -0
- package/dist/api/wait.d.ts +17 -0
- package/dist/api/wait.d.ts.map +1 -0
- package/dist/api/wait.js +37 -0
- package/dist/api/wait.js.map +1 -0
- package/dist/api/workspaces.d.ts +33 -0
- package/dist/api/workspaces.d.ts.map +1 -0
- package/dist/api/workspaces.js +159 -0
- package/dist/api/workspaces.js.map +1 -0
- package/dist/auth/auth-file.d.ts +37 -0
- package/dist/auth/auth-file.d.ts.map +1 -0
- package/dist/auth/auth-file.js +100 -0
- package/dist/auth/auth-file.js.map +1 -0
- package/dist/auth/browser-login.d.ts +45 -0
- package/dist/auth/browser-login.d.ts.map +1 -0
- package/dist/auth/browser-login.js +195 -0
- package/dist/auth/browser-login.js.map +1 -0
- package/dist/auth/config-file.d.ts +4 -0
- package/dist/auth/config-file.d.ts.map +1 -0
- package/dist/auth/config-file.js +52 -0
- package/dist/auth/config-file.js.map +1 -0
- package/dist/auth/models-providers.d.ts +17 -0
- package/dist/auth/models-providers.d.ts.map +1 -0
- package/dist/auth/models-providers.js +63 -0
- package/dist/auth/models-providers.js.map +1 -0
- package/dist/auth/paths.d.ts +15 -0
- package/dist/auth/paths.d.ts.map +1 -0
- package/dist/auth/paths.js +25 -0
- package/dist/auth/paths.js.map +1 -0
- package/dist/auth/provider-ids.d.ts +4 -0
- package/dist/auth/provider-ids.d.ts.map +1 -0
- package/dist/auth/provider-ids.js +12 -0
- package/dist/auth/provider-ids.js.map +1 -0
- package/dist/auth/resolve.d.ts +19 -0
- package/dist/auth/resolve.d.ts.map +1 -0
- package/dist/auth/resolve.js +37 -0
- package/dist/auth/resolve.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/completion.d.ts +2 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +71 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/flags.d.ts +16 -0
- package/dist/commands/flags.d.ts.map +1 -0
- package/dist/commands/flags.js +23 -0
- package/dist/commands/flags.js.map +1 -0
- package/dist/commands/guard.d.ts +10 -0
- package/dist/commands/guard.d.ts.map +1 -0
- package/dist/commands/guard.js +41 -0
- package/dist/commands/guard.js.map +1 -0
- package/dist/commands/help.d.ts +4 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +19 -0
- package/dist/commands/help.js.map +1 -0
- package/dist/commands/login.d.ts +18 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +80 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +14 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +41 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/prompt.d.ts +9 -0
- package/dist/commands/prompt.d.ts.map +1 -0
- package/dist/commands/prompt.js +21 -0
- package/dist/commands/prompt.js.map +1 -0
- package/dist/commands/registry.d.ts +16 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +35 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.d.ts.map +1 -0
- package/dist/commands/ssh.js +90 -0
- package/dist/commands/ssh.js.map +1 -0
- package/dist/commands/table.d.ts +3 -0
- package/dist/commands/table.d.ts.map +1 -0
- package/dist/commands/table.js +10 -0
- package/dist/commands/table.js.map +1 -0
- package/dist/commands/update.d.ts +16 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +57 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/version.d.ts +2 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +6 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/commands/workspace.d.ts +15 -0
- package/dist/commands/workspace.d.ts.map +1 -0
- package/dist/commands/workspace.js +206 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +30 -0
- package/dist/run.js.map +1 -0
- package/dist/ssh/bridge.d.ts +25 -0
- package/dist/ssh/bridge.d.ts.map +1 -0
- package/dist/ssh/bridge.js +95 -0
- package/dist/ssh/bridge.js.map +1 -0
- package/dist/ssh/config.d.ts +65 -0
- package/dist/ssh/config.d.ts.map +1 -0
- package/dist/ssh/config.js +198 -0
- package/dist/ssh/config.js.map +1 -0
- package/dist/ssh/connect.d.ts +27 -0
- package/dist/ssh/connect.d.ts.map +1 -0
- package/dist/ssh/connect.js +67 -0
- package/dist/ssh/connect.js.map +1 -0
- package/dist/ssh/tunnel.d.ts +29 -0
- package/dist/ssh/tunnel.d.ts.map +1 -0
- package/dist/ssh/tunnel.js +36 -0
- package/dist/ssh/tunnel.js.map +1 -0
- package/dist/test-support.d.ts +30 -0
- package/dist/test-support.d.ts.map +1 -0
- package/dist/test-support.js +67 -0
- package/dist/test-support.js.map +1 -0
- package/dist/updates/check.d.ts +18 -0
- package/dist/updates/check.d.ts.map +1 -0
- package/dist/updates/check.js +90 -0
- package/dist/updates/check.js.map +1 -0
- package/dist/updates/self.d.ts +36 -0
- package/dist/updates/self.d.ts.map +1 -0
- package/dist/updates/self.js +103 -0
- package/dist/updates/self.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
- package/src/api/http.ts +70 -0
- package/src/api/keys.ts +40 -0
- package/src/api/me.ts +48 -0
- package/src/api/resolver.test.ts +109 -0
- package/src/api/resolver.ts +72 -0
- package/src/api/resources.test.ts +56 -0
- package/src/api/resources.ts +92 -0
- package/src/api/templates.ts +28 -0
- package/src/api/tokens.ts +80 -0
- package/src/api/types.ts +50 -0
- package/src/api/wait.ts +45 -0
- package/src/api/workspaces.ts +234 -0
- package/src/auth/auth-file.test.ts +176 -0
- package/src/auth/auth-file.ts +103 -0
- package/src/auth/browser-login.test.ts +93 -0
- package/src/auth/browser-login.ts +247 -0
- package/src/auth/config-file.test.ts +68 -0
- package/src/auth/config-file.ts +50 -0
- package/src/auth/models-providers.test.ts +63 -0
- package/src/auth/models-providers.ts +76 -0
- package/src/auth/paths.ts +28 -0
- package/src/auth/provider-ids.ts +15 -0
- package/src/auth/resolve.test.ts +72 -0
- package/src/auth/resolve.ts +46 -0
- package/src/cli.ts +16 -0
- package/src/commands/completion.test.ts +31 -0
- package/src/commands/completion.ts +77 -0
- package/src/commands/flags.ts +29 -0
- package/src/commands/guard.ts +44 -0
- package/src/commands/help.ts +20 -0
- package/src/commands/login.test.ts +159 -0
- package/src/commands/login.ts +94 -0
- package/src/commands/logout.test.ts +89 -0
- package/src/commands/logout.ts +53 -0
- package/src/commands/prompt.ts +21 -0
- package/src/commands/registry.ts +50 -0
- package/src/commands/ssh.test.ts +101 -0
- package/src/commands/ssh.ts +112 -0
- package/src/commands/table.test.ts +24 -0
- package/src/commands/table.ts +10 -0
- package/src/commands/update.test.ts +91 -0
- package/src/commands/update.ts +78 -0
- package/src/commands/version.ts +6 -0
- package/src/commands/whoami.test.ts +72 -0
- package/src/commands/whoami.ts +36 -0
- package/src/commands/workspace.test.ts +360 -0
- package/src/commands/workspace.ts +250 -0
- package/src/run.test.ts +53 -0
- package/src/run.ts +34 -0
- package/src/ssh/bridge.test.ts +70 -0
- package/src/ssh/bridge.ts +110 -0
- package/src/ssh/config.test.ts +213 -0
- package/src/ssh/config.ts +233 -0
- package/src/ssh/connect.test.ts +123 -0
- package/src/ssh/connect.ts +98 -0
- package/src/ssh/tunnel.test.ts +119 -0
- package/src/ssh/tunnel.ts +64 -0
- package/src/test-support.ts +77 -0
- package/src/updates/check.test.ts +99 -0
- package/src/updates/check.ts +117 -0
- package/src/updates/self.test.ts +117 -0
- package/src/updates/self.ts +131 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import { fetchWithTimeout } from "../api/http.js"
|
|
4
|
+
import { resolveSshPaths } from "../ssh/config.js"
|
|
5
|
+
import { VERSION } from "../version.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Daily background update check against the GitHub Releases API.
|
|
9
|
+
*
|
|
10
|
+
* The check must never block or fail a real command: every failure mode
|
|
11
|
+
* resolves to "no notification". Results cache in
|
|
12
|
+
* ~/.config/kimchi/kimchictl/update-check.json for 24h.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const LATEST_RELEASE_URL = "https://api.github.com/repos/getkimchi/kimchictl/releases/latest"
|
|
16
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
17
|
+
const FETCH_TIMEOUT_MS = 2500
|
|
18
|
+
|
|
19
|
+
interface UpdateCache {
|
|
20
|
+
latestVersion: string
|
|
21
|
+
checkedAt: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Semantic-ish comparison: 0.10.0 > 0.9.9. Only digits matter; pre-releases compare numerically too. */
|
|
25
|
+
export function isNewer(latest: string, current: string): boolean {
|
|
26
|
+
const parse = (v: string) =>
|
|
27
|
+
v
|
|
28
|
+
.replace(/^v/, "")
|
|
29
|
+
.split(".")
|
|
30
|
+
.map((part) => Number.parseInt(part, 10) || 0)
|
|
31
|
+
const a = parse(latest)
|
|
32
|
+
const b = parse(current)
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
const diff = (a[i] ?? 0) - (b[i] ?? 0)
|
|
35
|
+
if (diff !== 0) return diff > 0
|
|
36
|
+
}
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UpdateCheckResult {
|
|
41
|
+
latest: string
|
|
42
|
+
current: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readCache(path: string): Promise<UpdateCache | undefined> {
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(await readFile(path, "utf-8")) as Partial<UpdateCache>
|
|
48
|
+
if (typeof raw.latestVersion === "string" && typeof raw.checkedAt === "string") {
|
|
49
|
+
return { latestVersion: raw.latestVersion, checkedAt: raw.checkedAt }
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined
|
|
54
|
+
return undefined // corrupt cache → treat as stale (refetched below)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function writeCache(path: string, cache: UpdateCache): Promise<void> {
|
|
59
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
60
|
+
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, { mode: 0o600 })
|
|
61
|
+
await chmod(path, 0o600)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CheckForUpdateOptions {
|
|
65
|
+
env?: NodeJS.ProcessEnv
|
|
66
|
+
fetch?: typeof globalThis.fetch
|
|
67
|
+
now?: () => Date
|
|
68
|
+
/** Version to compare against — tests inject; defaults to the baked-in VERSION. */
|
|
69
|
+
currentVersion?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Returns {latest, current} when a newer release exists, otherwise undefined. Never throws. */
|
|
73
|
+
export async function checkForUpdate(options: CheckForUpdateOptions = {}): Promise<UpdateCheckResult | undefined> {
|
|
74
|
+
try {
|
|
75
|
+
const current = options.currentVersion ?? VERSION
|
|
76
|
+
// Source checkouts and dev builds are not versioned against releases.
|
|
77
|
+
if (current.endsWith("-dev")) return undefined
|
|
78
|
+
|
|
79
|
+
const env = options.env ?? process.env
|
|
80
|
+
if (env.KIMCHICTL_NO_UPDATE_CHECK) return undefined
|
|
81
|
+
|
|
82
|
+
const now = options.now ?? (() => new Date())
|
|
83
|
+
const cachePath = join(resolveSshPaths(env).home, "update-check.json")
|
|
84
|
+
|
|
85
|
+
const cached = await readCache(cachePath)
|
|
86
|
+
if (cached && now().getTime() - new Date(cached.checkedAt).getTime() < CHECK_INTERVAL_MS) {
|
|
87
|
+
return isNewer(cached.latestVersion, current) ? { latest: cached.latestVersion, current } : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const fetchImpl = options.fetch ?? globalThis.fetch
|
|
91
|
+
const resp = await fetchWithTimeout(
|
|
92
|
+
LATEST_RELEASE_URL,
|
|
93
|
+
{ headers: { "User-Agent": `kimchictl/${current}` } },
|
|
94
|
+
fetchImpl,
|
|
95
|
+
FETCH_TIMEOUT_MS,
|
|
96
|
+
)
|
|
97
|
+
if (!resp.ok) return undefined
|
|
98
|
+
const data = (await resp.json()) as { tag_name?: string }
|
|
99
|
+
if (typeof data.tag_name !== "string") return undefined
|
|
100
|
+
|
|
101
|
+
const latest = data.tag_name.replace(/^v/, "")
|
|
102
|
+
await writeCache(cachePath, { latestVersion: latest, checkedAt: now().toISOString() }).catch(() => {
|
|
103
|
+
// Cache writes are best-effort only.
|
|
104
|
+
})
|
|
105
|
+
return isNewer(latest, current) ? { latest, current } : undefined
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Post the update hint to stderr. Extracted so cli.ts stays a one-liner. */
|
|
112
|
+
export async function maybeNotifyUpdate(options: CheckForUpdateOptions = {}): Promise<void> {
|
|
113
|
+
const result = await checkForUpdate(options)
|
|
114
|
+
if (result) {
|
|
115
|
+
console.error(`⬆ kimchictl ${result.latest} available (current ${result.current}) — update: kimchictl update`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { writeFile } from "node:fs/promises"
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
4
|
+
import { cleanupTempDirs, makeTempDir } from "../test-support.js"
|
|
5
|
+
import { detectInstallMethod, platformAssetName, selfUpdateToVersion } from "./self.js"
|
|
6
|
+
|
|
7
|
+
afterEach(() => cleanupTempDirs())
|
|
8
|
+
|
|
9
|
+
describe("detectInstallMethod", () => {
|
|
10
|
+
it("recognizes Homebrew, runtimes, and plain binaries", () => {
|
|
11
|
+
expect(detectInstallMethod(() => "/opt/homebrew/Cellar/kimchictl/0.2.0/bin/kimchictl")).toBe("homebrew")
|
|
12
|
+
expect(detectInstallMethod(() => "/usr/local/bin/node")).toBe("runtime")
|
|
13
|
+
expect(detectInstallMethod(() => "/opt/homebrew/bin/bun")).toBe("runtime")
|
|
14
|
+
expect(detectInstallMethod(() => "/usr/local/bin/kimchictl")).toBe("binary")
|
|
15
|
+
expect(detectInstallMethod(() => "/home/u/.local/bin/kimchictl")).toBe("binary")
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe("platformAssetName", () => {
|
|
20
|
+
it("maps node platform/arch to release asset names", () => {
|
|
21
|
+
expect(platformAssetName("darwin", "arm64")).toBe("kimchictl-darwin-arm64")
|
|
22
|
+
expect(platformAssetName("linux", "x64")).toBe("kimchictl-linux-x64")
|
|
23
|
+
expect(() => platformAssetName("win32" as NodeJS.Platform, "x64")).toThrow("unsupported platform")
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe("selfUpdateToVersion", () => {
|
|
28
|
+
function scenario(options?: { withChecksums?: boolean; tamper?: boolean }) {
|
|
29
|
+
const tmp = makeTempDir()
|
|
30
|
+
const binary = Buffer.from("#!/bin/fake kimchictl binary\n")
|
|
31
|
+
const downloads = new Map<string, Buffer>()
|
|
32
|
+
// Checksum of the PRISTINE bytes; tamper must corrupt the served binary only.
|
|
33
|
+
if (options?.withChecksums !== false) {
|
|
34
|
+
downloads.set(
|
|
35
|
+
"checksums.txt",
|
|
36
|
+
Buffer.from(
|
|
37
|
+
`${createHash("sha256").update(binary).digest("hex")} kimchictl-${process.platform === "darwin" ? "darwin" : "linux"}-${process.arch === "arm64" ? "arm64" : "x64"}\n`,
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
if (options?.tamper) binary[0] = 0x21
|
|
42
|
+
downloads.set("kimchictl-darwin-arm64", binary)
|
|
43
|
+
downloads.set("kimchictl-linux-arm64", binary)
|
|
44
|
+
downloads.set("kimchictl-darwin-x64", binary)
|
|
45
|
+
downloads.set("kimchictl-linux-x64", binary)
|
|
46
|
+
|
|
47
|
+
const written = new Map<string, Buffer>()
|
|
48
|
+
const renamed: string[][] = []
|
|
49
|
+
return {
|
|
50
|
+
tmp,
|
|
51
|
+
binary,
|
|
52
|
+
// Real download writes to dest — selfUpdateToVersion re-reads the staged
|
|
53
|
+
// file from disk when checksum-verifying.
|
|
54
|
+
download: async (url: string, dest: string) => {
|
|
55
|
+
const name = url.split("/").pop() ?? ""
|
|
56
|
+
const bytes = downloads.get(name)
|
|
57
|
+
if (!bytes) throw new Error(`404 ${name}`)
|
|
58
|
+
written.set(dest, bytes)
|
|
59
|
+
await writeFile(dest, bytes)
|
|
60
|
+
},
|
|
61
|
+
renamed,
|
|
62
|
+
renameFn: async (from: string, to: string) => {
|
|
63
|
+
renamed.push([from, to])
|
|
64
|
+
},
|
|
65
|
+
chmodFn: async () => {},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
it("downloads the platform asset, verifies the checksum, and renames over the target", async () => {
|
|
70
|
+
const s = scenario()
|
|
71
|
+
|
|
72
|
+
await selfUpdateToVersion({
|
|
73
|
+
targetPath: "/usr/local/bin/kimchictl",
|
|
74
|
+
version: "0.3.0",
|
|
75
|
+
download: s.download,
|
|
76
|
+
renameFn: s.renameFn,
|
|
77
|
+
chmodFn: s.chmodFn,
|
|
78
|
+
tmpRoot: s.tmp,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const stagedContent = [...s.renamed][0]
|
|
82
|
+
expect(stagedContent?.[1]).toBe("/usr/local/bin/kimchictl")
|
|
83
|
+
expect(stagedContent?.[0]).toContain("kimchictl-update-")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("aborts on checksum mismatch", async () => {
|
|
87
|
+
const s = scenario({ tamper: true })
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
selfUpdateToVersion({
|
|
91
|
+
targetPath: "/usr/local/bin/kimchictl",
|
|
92
|
+
version: "0.3.0",
|
|
93
|
+
download: s.download,
|
|
94
|
+
renameFn: s.renameFn,
|
|
95
|
+
chmodFn: s.chmodFn,
|
|
96
|
+
tmpRoot: s.tmp,
|
|
97
|
+
}),
|
|
98
|
+
).rejects.toThrow(/checksum mismatch/)
|
|
99
|
+
|
|
100
|
+
expect(s.renamed.length).toBe(0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("proceeds without verification when checksums.txt is absent (older releases)", async () => {
|
|
104
|
+
const s = scenario({ withChecksums: false })
|
|
105
|
+
|
|
106
|
+
await selfUpdateToVersion({
|
|
107
|
+
targetPath: "/usr/local/bin/kimchictl",
|
|
108
|
+
version: "0.3.0",
|
|
109
|
+
download: s.download,
|
|
110
|
+
renameFn: s.renameFn,
|
|
111
|
+
chmodFn: s.chmodFn,
|
|
112
|
+
tmpRoot: s.tmp,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(s.renamed.length).toBe(1)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { chmod, mkdtemp, readFile, rename, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { basename, join } from "node:path"
|
|
5
|
+
import { fetchWithTimeout } from "../api/http.js"
|
|
6
|
+
import { VERSION } from "../version.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Self-update plumbing: detect how kimchictl was installed and, for binary
|
|
10
|
+
* installs, swap the executable in place from the latest GitHub release.
|
|
11
|
+
* Homebrew/npm/RTE (runtime) installs delegate to their own updaters.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type InstallMethod = "binary" | "homebrew" | "runtime"
|
|
15
|
+
|
|
16
|
+
/** realpath seam: tests feed synthetic paths. */
|
|
17
|
+
export function detectInstallMethod(realpath: () => string): InstallMethod {
|
|
18
|
+
const target = realpath()
|
|
19
|
+
if (/\/(Cellar|linuxbrew)\/[^/]*kimchictl?\//.test(target) || /homebrew.*kimchictl/i.test(target)) {
|
|
20
|
+
return "homebrew"
|
|
21
|
+
}
|
|
22
|
+
const base = basename(target)
|
|
23
|
+
if (base === "node" || base === "bun" || base === "node.exe") {
|
|
24
|
+
return "runtime"
|
|
25
|
+
}
|
|
26
|
+
return "binary"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** realpath of the running executable, best-effort. */
|
|
30
|
+
export function defaultInstallMethod(execPath = process.execPath): InstallMethod {
|
|
31
|
+
// bun-compiled binaries ARE the install (execPath is the binary); node/bun
|
|
32
|
+
// runtime hosts are npm/dev installs.
|
|
33
|
+
const base = basename(execPath)
|
|
34
|
+
if (base === "node" || base === "bun" || base === "node.exe") return "runtime"
|
|
35
|
+
return detectInstallMethod(() => execPath)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Release asset name for this platform, e.g. kimchictl-darwin-arm64. */
|
|
39
|
+
export function platformAssetName(platform: NodeJS.Platform = process.platform, arch: string = process.arch): string {
|
|
40
|
+
const os = platform === "darwin" ? "darwin" : platform === "linux" ? "linux" : undefined
|
|
41
|
+
const cpu = arch === "arm64" ? "arm64" : arch === "x64" ? "x64" : undefined
|
|
42
|
+
if (!os || !cpu) {
|
|
43
|
+
throw new Error(`unsupported platform for self-update: ${platform}/${arch}`)
|
|
44
|
+
}
|
|
45
|
+
return `kimchictl-${os}-${cpu}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class SelfUpdateError extends Error {
|
|
49
|
+
constructor(message: string) {
|
|
50
|
+
super(message)
|
|
51
|
+
this.name = "SelfUpdateError"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Distinct from a checksums-download failure: a verified mismatch always aborts. */
|
|
56
|
+
export class ChecksumMismatchError extends SelfUpdateError {
|
|
57
|
+
constructor(message: string) {
|
|
58
|
+
super(message)
|
|
59
|
+
this.name = "ChecksumMismatchError"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SelfUpdateOptions {
|
|
64
|
+
targetPath: string
|
|
65
|
+
version: string // e.g. "0.2.0" — bare tag (release.yml tags are bare)
|
|
66
|
+
fetch?: typeof globalThis.fetch
|
|
67
|
+
download?: (url: string, destPath: string) => Promise<void>
|
|
68
|
+
chmodFn?: (path: string, mode: number) => Promise<void>
|
|
69
|
+
renameFn?: (from: string, to: string) => Promise<void>
|
|
70
|
+
tmpRoot?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function defaultDownload(url: string, destPath: string): Promise<void> {
|
|
74
|
+
const resp = await fetchWithTimeout(url, {}, globalThis.fetch, 60_000)
|
|
75
|
+
if (!resp.ok) {
|
|
76
|
+
throw new SelfUpdateError(`download failed: HTTP ${resp.status} for ${url}`)
|
|
77
|
+
}
|
|
78
|
+
await writeFile(destPath, new Uint8Array(await resp.arrayBuffer()))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Download the release asset for the current platform, checksum-verify it
|
|
83
|
+
* against release checksums.txt, then atomically rename it over targetPath.
|
|
84
|
+
*/
|
|
85
|
+
export async function selfUpdateToVersion(options: SelfUpdateOptions): Promise<void> {
|
|
86
|
+
const base = `https://github.com/getkimchi/kimchictl/releases/download/${options.version}`
|
|
87
|
+
const asset = platformAssetName()
|
|
88
|
+
const download = options.download ?? defaultDownload
|
|
89
|
+
const chmodFn = options.chmodFn ?? chmod
|
|
90
|
+
const renameFn = options.renameFn ?? rename
|
|
91
|
+
|
|
92
|
+
const tmpDir = await mkdtemp(join(options.tmpRoot ?? tmpdir(), "kimchictl-update-"))
|
|
93
|
+
const staged = join(tmpDir, "kimchictl")
|
|
94
|
+
|
|
95
|
+
await download(`${base}/${asset}`, staged)
|
|
96
|
+
|
|
97
|
+
// Verify against the release checksums.txt when it exists.
|
|
98
|
+
const checksumsPath = join(tmpDir, "checksums.txt")
|
|
99
|
+
try {
|
|
100
|
+
await download(`${base}/checksums.txt`, checksumsPath)
|
|
101
|
+
const checksums = await readFile(checksumsPath, "utf-8")
|
|
102
|
+
const expected = checksums
|
|
103
|
+
.split("\n")
|
|
104
|
+
.map((line) => line.trim())
|
|
105
|
+
.find((line) => line.endsWith(` ${asset}`))
|
|
106
|
+
?.split(/\s+/)[0]
|
|
107
|
+
if (expected) {
|
|
108
|
+
const actual = createHash("sha256")
|
|
109
|
+
.update(await readFile(staged))
|
|
110
|
+
.digest("hex")
|
|
111
|
+
if (actual !== expected) {
|
|
112
|
+
throw new ChecksumMismatchError(
|
|
113
|
+
`checksum mismatch for ${asset} — aborting update (expected ${expected}, got ${actual})`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err instanceof ChecksumMismatchError) throw err
|
|
119
|
+
// Fetching checksums.txt itself failed (older releases may lack it) — proceed without verification.
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await chmodFn(staged, 0o755)
|
|
123
|
+
await renameFn(staged, options.targetPath)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Guard for the update command — plain error for guard.ts, no new error family. */
|
|
127
|
+
export function assertNotDevBuild(): void {
|
|
128
|
+
if (VERSION.endsWith("-dev")) {
|
|
129
|
+
throw new SelfUpdateError("self-update is not available from a dev build (VERSION is 0.0.0-dev)")
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/version.ts
ADDED