@edspencer/paddock 0.81.0 → 0.82.1
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/package.json +1 -1
- package/packages/server/dist/cli/args.d.ts +56 -31
- package/packages/server/dist/cli/args.js +210 -24
- package/packages/server/dist/cli/busy-command.js +1 -1
- package/packages/server/dist/cli/busy.js +10 -12
- package/packages/server/dist/cli/exit-codes.d.ts +21 -45
- package/packages/server/dist/cli/exit-codes.js +42 -45
- package/packages/server/dist/cli/output.js +5 -6
- package/packages/server/dist/cli/paddock.js +60 -30
- package/packages/server/dist/cli/service/index.d.ts +54 -2
- package/packages/server/dist/cli/service/index.js +198 -25
- package/packages/server/dist/cli/service/launchd.d.ts +31 -84
- package/packages/server/dist/cli/service/launchd.js +104 -304
- package/packages/server/dist/cli/service/logs.d.ts +9 -28
- package/packages/server/dist/cli/service/logs.js +33 -75
- package/packages/server/dist/cli/service/manager.d.ts +93 -0
- package/packages/server/dist/cli/service/manager.js +21 -0
- package/packages/server/dist/cli/service/path-env.d.ts +7 -18
- package/packages/server/dist/cli/service/path-env.js +30 -81
- package/packages/server/dist/cli/service/ready.d.ts +30 -0
- package/packages/server/dist/cli/service/ready.js +35 -0
- package/packages/server/dist/cli/service/spec.d.ts +4 -31
- package/packages/server/dist/cli/service/spec.js +9 -0
- package/packages/server/dist/cli/service/systemd.d.ts +27 -101
- package/packages/server/dist/cli/service/systemd.js +106 -235
- package/packages/server/dist/cli/update/health.d.ts +48 -0
- package/packages/server/dist/cli/update/health.js +64 -0
- package/packages/server/dist/cli/update/install-method.d.ts +84 -0
- package/packages/server/dist/cli/update/install-method.js +183 -0
- package/packages/server/dist/cli/update/lock.d.ts +35 -0
- package/packages/server/dist/cli/update/lock.js +74 -0
- package/packages/server/dist/cli/update/package-manager.d.ts +74 -0
- package/packages/server/dist/cli/update/package-manager.js +156 -0
- package/packages/server/dist/cli/update/registry.d.ts +52 -0
- package/packages/server/dist/cli/update/registry.js +102 -0
- package/packages/server/dist/cli/update/semver.d.ts +45 -0
- package/packages/server/dist/cli/update/semver.js +182 -0
- package/packages/server/dist/cli/update/update-check.d.ts +69 -0
- package/packages/server/dist/cli/update/update-check.js +86 -0
- package/packages/server/dist/cli/update/update.d.ts +126 -0
- package/packages/server/dist/cli/update/update.js +408 -0
- package/packages/server/dist/cli/update-command.d.ts +20 -0
- package/packages/server/dist/cli/update-command.js +111 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{DeleteProjectDialog-D8oIJ8HC.js → DeleteProjectDialog-CgHpQctt.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-CG6cf-0i.js +2 -0
- package/packages/web/dist/assets/{DiscoverView-o0KCr3aB.js → DiscoverView-BKqI0I1E.js} +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-RSJz7oL3.js → InstanceConfigPage-DL3raCK1.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-39mafH8b.js → MarkdownRenderer-DcjEuf3s.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-Bq7GVguY.js → ProjectRedirect-DerkA-nE.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-BoBIru1g.js → ProjectView-NwfC3_w7.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-mJ4gqbr6.js → ProjectsGrid-BeORa0UH.js} +1 -1
- package/packages/web/dist/assets/{RootHome-C0kwFyYp.js → RootHome-BYJs8qJ6.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-MkhXlB8z.js → Surfaces-w-kEaAYU.js} +1 -1
- package/packages/web/dist/assets/{arc-DExlwAwO.js → arc-Bv56hasM.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CZly3W79.js → architectureDiagram-3BPJPVTR-BHEknx9i.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-AgES73i3.js → blockDiagram-GPEHLZMM-DtxTMrPX.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-N9mD8Pfk.js → c4Diagram-AAUBKEIU-DRpEYOlM.js} +1 -1
- package/packages/web/dist/assets/channel-CdIiAqY2.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-Dsx_kfUl.js → chunk-2J33WTMH-S4s7GCg7.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-BQjqcjPj.js → chunk-4BX2VUAB-BkWVPLf1.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CCxpYQpY.js → chunk-55IACEB6-4-FQp01F.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-DpFRdnUa.js → chunk-727SXJPM-BeyDWdIb.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-D2BAlzoO.js → chunk-AQP2D5EJ-ZSxKy745.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BFaMRzUF.js → chunk-FMBD7UC4-Bq8uV9j-.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-B9NGJ9xZ.js → chunk-ND2GUHAM-BOFsRyHk.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CF1IHyMS.js → chunk-QZHKN3VN-uZ6oNc4m.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCCUHba0.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCCUHba0.js +2 -0
- package/packages/web/dist/assets/{core-XjElsvQ7.js → core-CYuJAM2U.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CF972Flx.js → cose-bilkent-S5V4N54A-DIzkVSFb.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-DsneI8YE.js → dagre-BM42HDAG-CHR6jJBv.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BHyrVzIk.js → diagram-2AECGRRQ-BAFXCH8Q.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Dj4q8xEi.js → diagram-5GNKFQAL-BJ5t5gbz.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BHe3Puv_.js → diagram-KO2AKTUF-D9Xt0IVg.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-wzt6G2FE.js → diagram-LMA3HP47-Bq38u6sy.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-CZMbMRJq.js → diagram-OG6HWLK6-CMQVqkpi.js} +1 -1
- package/packages/web/dist/assets/{documentTitle-BwTIA8rI.js → documentTitle-M1lrfXRU.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Dtu14BL1.js → erDiagram-TEJ5UH35-C63M1g9b.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-T34ql9j0.js → flowDiagram-I6XJVG4X-D3j8ACN7.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DQLOeCXK.js → ganttDiagram-6RSMTGT7-B1Fd1A2Y.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-Bk11zea6.js → gitGraphDiagram-PVQCEYII-CEhZhDdR.js} +1 -1
- package/packages/web/dist/assets/{index-CvaWErFv.js → index-1E9yHciS.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CsE83lu0.js → infoDiagram-5YYISTIA-Ctquaew6.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DNVgA93n.js → ishikawaDiagram-YF4QCWOH-DV4GvA1R.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CLpSNjM3.js → journeyDiagram-JHISSGLW-C_Cy9waA.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BRzuXcKr.js → kanban-definition-UN3LZRKU-CGr9ctEf.js} +1 -1
- package/packages/web/dist/assets/{linear-BxOawONt.js → linear-D3U8NOkt.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Dvs1QCcz.js → mermaid.core-BiArdWKV.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Dmt7pYwF.js → mindmap-definition-RKZ34NQL-CZIpzj8Z.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BHiZaJD-.js → pieDiagram-4H26LBE5-1MaeS3rt.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-U0A1noRE.js → quadrantDiagram-W4KKPZXB-B5pgQ54e.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZLsr6yi.js → requirementDiagram-4Y6WPE33-BNnxrpGr.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BTlLGILU.js → sankeyDiagram-5OEKKPKP-DsxVH-hM.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DpSS_Gb1.js → sequenceDiagram-3UESZ5HK-Ke9LcFZI.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-D9srE-tV.js → stateDiagram-AJRCARHV-UJzmflND.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgUc3HkP.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BYQ5CKab.js → timeline-definition-PNZ67QCA-BRjNALVI.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B4W2CtSV.js → vennDiagram-CIIHVFJN-ClMAspxz.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY--XUV7GvD.js → wardley-L42UT6IY-UPv4JEmz.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bl2VIEGY.js → wardleyDiagram-YWT4CUSO-BgYJbdEx.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Bj2H9Rcf.js → xychartDiagram-2RQKCTM6-Bfk5HjGx.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/server/dist/cli/service/backend.d.ts +0 -115
- package/packages/server/dist/cli/service/backend.js +0 -19
- package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +0 -2
- package/packages/web/dist/assets/channel-ExsAG7qT.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +0 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// From edspencer/cli-contract reference/health.ts @ 0.2; local changes: `.ts` import specifiers → `.js`.
|
|
2
|
+
// Asking `/api/health` (SPEC §8): once, until it answers, or until it answers with one version. The service commands
|
|
3
|
+
// wait on it before they claim success, and `update` waits for the target version (SPEC §5 step 12). It's an HTTP
|
|
4
|
+
// request to the URL a person would open, not a pid lookup: launchd and systemd both report a crash-looping job as
|
|
5
|
+
// running for most of each restart interval. Node built-ins only.
|
|
6
|
+
const healthUrl = (base) => new URL('/api/health', base).toString();
|
|
7
|
+
/** Asks once. Undefined means nothing answered (with a 2xx), which is an answer, not an error. */
|
|
8
|
+
export async function fetchHealth(o) {
|
|
9
|
+
try {
|
|
10
|
+
const res = await (o.fetch ?? globalThis.fetch)(healthUrl(o.url), { signal: AbortSignal.timeout(o.timeoutMs ?? 2000) });
|
|
11
|
+
if (!res.ok)
|
|
12
|
+
return undefined;
|
|
13
|
+
const body = (await res.json());
|
|
14
|
+
return { ok: body.ok === true, ...(typeof body.version === 'string' ? { version: body.version } : {}) };
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* How long to wait before saying it didn't answer. Generous, because it's only spent when something is wrong, and
|
|
22
|
+
* too short a wait says "didn't start" about a service that's only slow to boot.
|
|
23
|
+
*/
|
|
24
|
+
export const READY_TIMEOUT_MS = 20_000;
|
|
25
|
+
const defaults = (o) => ({
|
|
26
|
+
timeoutMs: o.timeoutMs ?? READY_TIMEOUT_MS,
|
|
27
|
+
intervalMs: o.intervalMs ?? 250,
|
|
28
|
+
now: o.now ?? (() => Date.now()),
|
|
29
|
+
sleep: o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
30
|
+
});
|
|
31
|
+
/** Polls until `/api/health` answers 2xx, or the time runs out. Each request has its own abort. */
|
|
32
|
+
export async function waitForReady(o) {
|
|
33
|
+
const { timeoutMs, intervalMs, now, sleep } = defaults(o);
|
|
34
|
+
const deadline = now() + timeoutMs;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const answer = await fetchHealth({ ...o, timeoutMs: Math.max(250, Math.min(2000, deadline - now())) });
|
|
37
|
+
if (answer !== undefined)
|
|
38
|
+
return 'ready';
|
|
39
|
+
if (deadline - now() <= 0)
|
|
40
|
+
return 'timeout';
|
|
41
|
+
await sleep(intervalMs);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Polls until `/api/health` reports exactly `version`. "It answers" isn't enough: the old server still shutting
|
|
46
|
+
* down, another instance on the port, and a supervisor that brought the old unit back all answer, and those are the
|
|
47
|
+
* failures a rollback is for. A wrong version and no answer are kept apart because they read differently in a report.
|
|
48
|
+
*/
|
|
49
|
+
export async function waitForVersion(o) {
|
|
50
|
+
const { timeoutMs, intervalMs, now, sleep } = defaults(o);
|
|
51
|
+
const deadline = now() + timeoutMs;
|
|
52
|
+
let last;
|
|
53
|
+
for (;;) {
|
|
54
|
+
const answer = await fetchHealth({ ...o, timeoutMs: Math.max(250, Math.min(2000, deadline - now())) });
|
|
55
|
+
if (answer?.version !== undefined) {
|
|
56
|
+
if (answer.version === o.version)
|
|
57
|
+
return { state: 'ok', version: o.version };
|
|
58
|
+
last = answer.version;
|
|
59
|
+
}
|
|
60
|
+
if (deadline - now() <= 0)
|
|
61
|
+
return last !== undefined ? { state: 'wrong-version', version: last } : { state: 'no-answer' };
|
|
62
|
+
await sleep(intervalMs);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** SPEC §6's rows, in its order. Matches `schemas/update-check.schema.json`. */
|
|
2
|
+
export type InstallMethod = 'docker' | 'managed' | 'npx' | 'checkout' | 'npm-global' | 'pnpm-global' | 'bun-global' | 'volta' | 'yarn-global' | 'dependency';
|
|
3
|
+
/** The package managers that can replace an install in place. */
|
|
4
|
+
export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'volta';
|
|
5
|
+
export interface FsLike {
|
|
6
|
+
existsSync(p: string): boolean;
|
|
7
|
+
readFileSync(p: string, encoding: 'utf8'): string;
|
|
8
|
+
realpathSync(p: string): string;
|
|
9
|
+
}
|
|
10
|
+
export interface DetectInput {
|
|
11
|
+
/** The published package's name: `@edspencer/paddock`. */
|
|
12
|
+
packageName: string;
|
|
13
|
+
/** The environment prefix: `PADDOCK` gives `PADDOCK_IN_DOCKER` and `PADDOCK_UPDATE_HINT`. */
|
|
14
|
+
envPrefix: string;
|
|
15
|
+
/** The script to walk up from: `process.argv[1]`. It's realpath'd here, because npm installs a bin as a symlink. */
|
|
16
|
+
entry: string;
|
|
17
|
+
env: NodeJS.ProcessEnv;
|
|
18
|
+
/** `npm root -g`, asked of the npm beside `process.execPath` (`package-manager.ts`). Undefined if npm can't say. */
|
|
19
|
+
npmGlobalRoot?: () => string | undefined;
|
|
20
|
+
fs?: FsLike;
|
|
21
|
+
}
|
|
22
|
+
export interface InstallInfo {
|
|
23
|
+
method: InstallMethod;
|
|
24
|
+
/**
|
|
25
|
+
* The folder whose `package.json` has our name or, when none does, the nearest folder with a `package.json` at all.
|
|
26
|
+
* The fallback is what makes a checkout of a tool whose published package is put together at build time (Paddock's)
|
|
27
|
+
* come out as a checkout rather than as nothing.
|
|
28
|
+
*/
|
|
29
|
+
packageRoot: string | undefined;
|
|
30
|
+
/** Whether the name was found, rather than fallen back from. */
|
|
31
|
+
named: boolean;
|
|
32
|
+
/** Whether `update` can install a new version itself, or must print a command and exit 5. */
|
|
33
|
+
canUpdate: boolean;
|
|
34
|
+
/** The package manager to install with, when it can. */
|
|
35
|
+
manager?: PackageManager;
|
|
36
|
+
/** `<TOOL>_UPDATE_HINT`, when it's set. */
|
|
37
|
+
hint?: string;
|
|
38
|
+
}
|
|
39
|
+
/** `PADDOCK` → `PADDOCK_IN_DOCKER`. */
|
|
40
|
+
export declare const inDockerVar: (prefix: string) => string;
|
|
41
|
+
/** `PADDOCK` → `PADDOCK_UPDATE_HINT`. */
|
|
42
|
+
export declare const updateHintVar: (prefix: string) => string;
|
|
43
|
+
/** Walks up from `entry` to the folder whose `package.json` has our name (see `InstallInfo.packageRoot`). */
|
|
44
|
+
export declare function findPackageRoot(entry: string, packageName: string, fs: FsLike): {
|
|
45
|
+
root: string | undefined;
|
|
46
|
+
named: boolean;
|
|
47
|
+
};
|
|
48
|
+
/** Whether a path is inside a `node_modules` folder: by segment, so `my-node_modules` isn't one. */
|
|
49
|
+
export declare function underNodeModules(p: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* The npx, pnpm dlx, bunx and yarn dlx caches (SPEC §6). `_npx` and `bunx-…` are matched as whole segments; pnpm's
|
|
52
|
+
* `…/dlx/<hash>` and yarn's `xfs-<hash>/dlx-<n>` need a segment that is `dlx` or starts `dlx-`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isEphemeralCachePath(p: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Which of SPEC §6's rows this install is, checked in the table's order.
|
|
57
|
+
*
|
|
58
|
+
* - **Docker beats managed.** A container an Ansible role also labelled is an image, but `hint` still carries the
|
|
59
|
+
* operator's sentence, and `updateCommand` prints it rather than guessing a `docker compose` line.
|
|
60
|
+
* - **No package root at all** reads as a checkout: it refuses to install, and "you're running from source" is the
|
|
61
|
+
* least wrong thing to say.
|
|
62
|
+
*/
|
|
63
|
+
export declare function detectInstall(input: DetectInput): InstallInfo;
|
|
64
|
+
export interface CommandInput {
|
|
65
|
+
packageName: string;
|
|
66
|
+
/** The version to install, once it's known. */
|
|
67
|
+
target?: string;
|
|
68
|
+
/** The image, for the Docker row: `ghcr.io/edspencer/paddock`. */
|
|
69
|
+
image?: string;
|
|
70
|
+
/** `sudo` in front: SPEC §5 step 5's not-writable case. */
|
|
71
|
+
sudo?: boolean;
|
|
72
|
+
/** What brings a checkout up to date, if not `git pull && npm install && npm run build`. */
|
|
73
|
+
checkoutCommand?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The command SPEC §3's notice prints for this install, and the note in brackets after it. The rows that can update
|
|
77
|
+
* themselves get the package manager's own command here; the notice prints `<tool> update` for those instead.
|
|
78
|
+
*/
|
|
79
|
+
export declare function updateCommand(info: InstallInfo, input: CommandInput): {
|
|
80
|
+
command: string;
|
|
81
|
+
note?: string;
|
|
82
|
+
};
|
|
83
|
+
/** A line a person can read, for each row. */
|
|
84
|
+
export declare function describeInstall(info: InstallInfo, packageName: string): string;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// From edspencer/cli-contract reference/install-method.ts @ 0.2; local changes: `.ts` import specifiers → `.js`.
|
|
2
|
+
// How was this copy installed? (SPEC §6.) `update` can't run `npm install -g` on a checkout, in a container or out of
|
|
3
|
+
// npx's cache, so before it touches anything it works out which of the table's ten rows it's in, and either picks the
|
|
4
|
+
// package manager or prints the command that would work and exits 5.
|
|
5
|
+
//
|
|
6
|
+
// Nothing here knows about a particular tool: the package name, the environment prefix and the entry script come in
|
|
7
|
+
// as data, and everything that touches the world (`npm root -g`, reading a manifest, resolving a symlink) is injected,
|
|
8
|
+
// so all ten rows can be tested against made-up layouts on one machine. Node built-ins only.
|
|
9
|
+
import fsDefault from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
/** `PADDOCK` → `PADDOCK_IN_DOCKER`. */
|
|
12
|
+
export const inDockerVar = (prefix) => `${prefix}_IN_DOCKER`;
|
|
13
|
+
/** `PADDOCK` → `PADDOCK_UPDATE_HINT`. */
|
|
14
|
+
export const updateHintVar = (prefix) => `${prefix}_UPDATE_HINT`;
|
|
15
|
+
/** Walks up from `entry` to the folder whose `package.json` has our name (see `InstallInfo.packageRoot`). */
|
|
16
|
+
export function findPackageRoot(entry, packageName, fs) {
|
|
17
|
+
let dir = path.dirname(realpathOr(entry, fs));
|
|
18
|
+
let nearest;
|
|
19
|
+
for (;;) {
|
|
20
|
+
const manifest = path.join(dir, 'package.json');
|
|
21
|
+
if (fs.existsSync(manifest)) {
|
|
22
|
+
nearest ??= dir;
|
|
23
|
+
try {
|
|
24
|
+
if (JSON.parse(fs.readFileSync(manifest, 'utf8')).name === packageName)
|
|
25
|
+
return { root: dir, named: true };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A broken package.json on the way up isn't ours; keep walking.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const parent = path.dirname(dir);
|
|
32
|
+
if (parent === dir)
|
|
33
|
+
return { root: nearest, named: false };
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function realpathOr(p, fs) {
|
|
38
|
+
try {
|
|
39
|
+
return fs.realpathSync(p);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const segments = (p) => p.split(/[/\\]/);
|
|
46
|
+
/** Whether a path is inside a `node_modules` folder: by segment, so `my-node_modules` isn't one. */
|
|
47
|
+
export function underNodeModules(p) {
|
|
48
|
+
return segments(p).includes('node_modules');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The npx, pnpm dlx, bunx and yarn dlx caches (SPEC §6). `_npx` and `bunx-…` are matched as whole segments; pnpm's
|
|
52
|
+
* `…/dlx/<hash>` and yarn's `xfs-<hash>/dlx-<n>` need a segment that is `dlx` or starts `dlx-`.
|
|
53
|
+
*/
|
|
54
|
+
export function isEphemeralCachePath(p) {
|
|
55
|
+
return segments(p).some((seg) => seg === '_npx' || seg === 'dlx' || seg.startsWith('dlx-') || seg.startsWith('bunx-'));
|
|
56
|
+
}
|
|
57
|
+
function hasSegments(p, run) {
|
|
58
|
+
const parts = segments(p);
|
|
59
|
+
for (let i = 0; i + run.length <= parts.length; i++) {
|
|
60
|
+
if (run.every((s, j) => parts[i + j] === s))
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Which of SPEC §6's rows this install is, checked in the table's order.
|
|
67
|
+
*
|
|
68
|
+
* - **Docker beats managed.** A container an Ansible role also labelled is an image, but `hint` still carries the
|
|
69
|
+
* operator's sentence, and `updateCommand` prints it rather than guessing a `docker compose` line.
|
|
70
|
+
* - **No package root at all** reads as a checkout: it refuses to install, and "you're running from source" is the
|
|
71
|
+
* least wrong thing to say.
|
|
72
|
+
*/
|
|
73
|
+
export function detectInstall(input) {
|
|
74
|
+
const fs = input.fs ?? fsDefault;
|
|
75
|
+
const env = input.env;
|
|
76
|
+
const { root, named } = findPackageRoot(input.entry, input.packageName, fs);
|
|
77
|
+
const hint = env[updateHintVar(input.envPrefix)];
|
|
78
|
+
const base = { packageRoot: root, named, ...(hint !== undefined && hint !== '' ? { hint } : {}) };
|
|
79
|
+
// 1. The tool's own Dockerfile sets this. Never `/.dockerenv`: dev containers have it, and a global install in one
|
|
80
|
+
// is a legitimate thing to update.
|
|
81
|
+
if (env[inDockerVar(input.envPrefix)] === '1')
|
|
82
|
+
return { ...base, method: 'docker', canUpdate: false };
|
|
83
|
+
// 2. Something outside owns this install, and said so.
|
|
84
|
+
if (base.hint !== undefined)
|
|
85
|
+
return { ...base, method: 'managed', canUpdate: false };
|
|
86
|
+
if (root === undefined)
|
|
87
|
+
return { ...base, method: 'checkout', canUpdate: false };
|
|
88
|
+
// 3. A hash-keyed cache that gets pruned.
|
|
89
|
+
if (isEphemeralCachePath(root))
|
|
90
|
+
return { ...base, method: 'npx', canUpdate: false };
|
|
91
|
+
// 4. A checkout or an `npm link`: not under a node_modules at all.
|
|
92
|
+
if (!underNodeModules(root))
|
|
93
|
+
return { ...base, method: 'checkout', canUpdate: false };
|
|
94
|
+
// 5. The package root is `<npm root -g>/<name>`.
|
|
95
|
+
const globalRoot = input.npmGlobalRoot?.();
|
|
96
|
+
if (globalRoot !== undefined && samePath(root, path.join(globalRoot, input.packageName), fs)) {
|
|
97
|
+
return { ...base, method: 'npm-global', canUpdate: true, manager: 'npm' };
|
|
98
|
+
}
|
|
99
|
+
// 6–9. The other global layouts, each known by a run of segments in its path.
|
|
100
|
+
if (hasSegments(root, ['pnpm', 'global']))
|
|
101
|
+
return { ...base, method: 'pnpm-global', canUpdate: true, manager: 'pnpm' };
|
|
102
|
+
if (hasSegments(root, ['.bun', 'install', 'global']))
|
|
103
|
+
return { ...base, method: 'bun-global', canUpdate: true, manager: 'bun' };
|
|
104
|
+
if (hasSegments(root, ['.volta', 'tools', 'image', 'packages']))
|
|
105
|
+
return { ...base, method: 'volta', canUpdate: true, manager: 'volta' };
|
|
106
|
+
// Yarn classic is unmaintained and berry has no global add, so SPEC §6 prints the command rather than running it.
|
|
107
|
+
if (hasSegments(root, ['yarn', 'global', 'node_modules']))
|
|
108
|
+
return { ...base, method: 'yarn-global', canUpdate: false };
|
|
109
|
+
// 10. Under some other node_modules: a dependency of a project.
|
|
110
|
+
return { ...base, method: 'dependency', canUpdate: false };
|
|
111
|
+
}
|
|
112
|
+
/** A global root is often reached through a symlink (Homebrew's, nvm's), so realpaths count too. */
|
|
113
|
+
function samePath(a, b, fs) {
|
|
114
|
+
return path.resolve(a) === path.resolve(b) || realpathOr(a, fs) === realpathOr(b, fs);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The command SPEC §3's notice prints for this install, and the note in brackets after it. The rows that can update
|
|
118
|
+
* themselves get the package manager's own command here; the notice prints `<tool> update` for those instead.
|
|
119
|
+
*/
|
|
120
|
+
export function updateCommand(info, input) {
|
|
121
|
+
const spec = input.target !== undefined ? `${input.packageName}@${input.target}` : input.packageName;
|
|
122
|
+
switch (info.method) {
|
|
123
|
+
case 'docker': {
|
|
124
|
+
if (info.hint !== undefined)
|
|
125
|
+
return { command: info.hint };
|
|
126
|
+
const tag = input.target !== undefined ? `:${input.target}` : '';
|
|
127
|
+
return { command: 'docker compose pull && docker compose up -d', ...(input.image !== undefined ? { note: `image ${input.image}${tag}` } : {}) };
|
|
128
|
+
}
|
|
129
|
+
case 'managed':
|
|
130
|
+
return { command: info.hint ?? 'ask whoever manages this install' };
|
|
131
|
+
case 'npx':
|
|
132
|
+
return { command: `npx ${input.packageName}@latest`, note: 'npx already runs the latest version each time' };
|
|
133
|
+
case 'checkout':
|
|
134
|
+
return { command: input.checkoutCommand ?? 'git pull && npm install && npm run build', note: 'this is a checkout' };
|
|
135
|
+
case 'npm-global':
|
|
136
|
+
return input.sudo === true
|
|
137
|
+
? { command: `sudo npm install -g ${spec}`, note: "the global folder isn't writable" }
|
|
138
|
+
: { command: `npm install -g ${spec}` };
|
|
139
|
+
case 'pnpm-global':
|
|
140
|
+
return { command: `pnpm add -g ${spec}` };
|
|
141
|
+
case 'bun-global':
|
|
142
|
+
return { command: `bun add -g ${spec}` };
|
|
143
|
+
case 'volta':
|
|
144
|
+
return { command: `volta install ${spec}` };
|
|
145
|
+
case 'yarn-global':
|
|
146
|
+
return { command: `yarn global add ${spec}` };
|
|
147
|
+
case 'dependency': {
|
|
148
|
+
const project = info.packageRoot !== undefined ? ` (${projectOf(info.packageRoot)})` : '';
|
|
149
|
+
return { command: `npm install ${spec}`, note: `run it in the project that depends on ${input.packageName}${project}` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** The folder above a dependency's `node_modules`. */
|
|
154
|
+
function projectOf(packageRoot) {
|
|
155
|
+
const parts = segments(packageRoot);
|
|
156
|
+
const i = parts.lastIndexOf('node_modules');
|
|
157
|
+
return i <= 0 ? packageRoot : packageRoot.slice(0, parts.slice(0, i).join('/').length);
|
|
158
|
+
}
|
|
159
|
+
/** A line a person can read, for each row. */
|
|
160
|
+
export function describeInstall(info, packageName) {
|
|
161
|
+
switch (info.method) {
|
|
162
|
+
case 'docker':
|
|
163
|
+
return 'running from a container image';
|
|
164
|
+
case 'managed':
|
|
165
|
+
return "managed by something outside this machine's package manager";
|
|
166
|
+
case 'npx':
|
|
167
|
+
return 'running from an npx or dlx cache';
|
|
168
|
+
case 'checkout':
|
|
169
|
+
return info.packageRoot !== undefined ? `running from a checkout at ${info.packageRoot}` : 'running from a checkout';
|
|
170
|
+
case 'npm-global':
|
|
171
|
+
return 'installed globally with npm';
|
|
172
|
+
case 'pnpm-global':
|
|
173
|
+
return 'installed globally with pnpm';
|
|
174
|
+
case 'bun-global':
|
|
175
|
+
return 'installed globally with bun';
|
|
176
|
+
case 'volta':
|
|
177
|
+
return 'installed with volta';
|
|
178
|
+
case 'yarn-global':
|
|
179
|
+
return 'installed globally with yarn (classic)';
|
|
180
|
+
case 'dependency':
|
|
181
|
+
return `installed as a dependency of another project (${packageName})`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare const LOCK_FILE = "update.lock";
|
|
2
|
+
export declare function lockPath(home: string): string;
|
|
3
|
+
export interface LockBody {
|
|
4
|
+
pid: number;
|
|
5
|
+
startedAt: string;
|
|
6
|
+
/** What holds it, for the message: `paddock update --to 0.81.0`. */
|
|
7
|
+
command?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Lock {
|
|
10
|
+
path: string;
|
|
11
|
+
/** Removes the file if it's still ours. Safe to call twice. */
|
|
12
|
+
release(): void;
|
|
13
|
+
}
|
|
14
|
+
/** Another live process holds the lock. */
|
|
15
|
+
export declare class LockHeldError extends Error {
|
|
16
|
+
name: string;
|
|
17
|
+
readonly holder: LockBody;
|
|
18
|
+
readonly path: string;
|
|
19
|
+
constructor(file: string, holder: LockBody);
|
|
20
|
+
}
|
|
21
|
+
export interface AcquireOptions {
|
|
22
|
+
home: string;
|
|
23
|
+
pid?: number;
|
|
24
|
+
command?: string;
|
|
25
|
+
isAlive?: (pid: number) => boolean;
|
|
26
|
+
now?: () => Date;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Takes the lock, or throws `LockHeldError`. After removing a stale lock it tries once more, and only once: a second
|
|
30
|
+
* `EEXIST` means another process won the same race just now, which is a real holder.
|
|
31
|
+
*/
|
|
32
|
+
export declare function acquireLock(o: AcquireOptions): Lock;
|
|
33
|
+
export declare function readLock(file: string): LockBody | null;
|
|
34
|
+
/** Removes the lock only if it names `pid`, or names nothing: never somebody else's. */
|
|
35
|
+
export declare function releaseLock(file: string, pid: number): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// From edspencer/cli-contract reference/lock.ts @ 0.2; local changes: `.ts` import specifiers → `.js`, and `exit-codes`, `output` and `runtime-file` imported from where Paddock keeps them (`../`, `../../`).
|
|
2
|
+
// `<home>/update.lock`: one update at a time (SPEC §5 step 1). Two at once is easy to cause (an Ansible run and a
|
|
3
|
+
// person at a terminal) and bad: the second stops the service the first just started, or installs an older target
|
|
4
|
+
// over a newer one, and the loser's rollback "restores" a version nobody asked for.
|
|
5
|
+
//
|
|
6
|
+
// So the lock is an `O_EXCL` create holding the pid. A live holder refuses with exit 1; a dead one (a crashed or
|
|
7
|
+
// killed update) is stale and is removed, or the install could never update itself again. Node built-ins only.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { isPidAlive } from '../../runtime-file.js';
|
|
11
|
+
export const LOCK_FILE = 'update.lock';
|
|
12
|
+
export function lockPath(home) {
|
|
13
|
+
return path.join(home, LOCK_FILE);
|
|
14
|
+
}
|
|
15
|
+
/** Another live process holds the lock. */
|
|
16
|
+
export class LockHeldError extends Error {
|
|
17
|
+
name = 'LockHeldError';
|
|
18
|
+
holder;
|
|
19
|
+
path;
|
|
20
|
+
constructor(file, holder) {
|
|
21
|
+
super(`another update is already running (pid ${holder.pid}, started ${holder.startedAt}). If that's wrong, remove ${file} and try again.`);
|
|
22
|
+
this.holder = holder;
|
|
23
|
+
this.path = file;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Takes the lock, or throws `LockHeldError`. After removing a stale lock it tries once more, and only once: a second
|
|
28
|
+
* `EEXIST` means another process won the same race just now, which is a real holder.
|
|
29
|
+
*/
|
|
30
|
+
export function acquireLock(o) {
|
|
31
|
+
const file = lockPath(o.home);
|
|
32
|
+
const pid = o.pid ?? process.pid;
|
|
33
|
+
const isAlive = o.isAlive ?? isPidAlive;
|
|
34
|
+
const body = {
|
|
35
|
+
pid,
|
|
36
|
+
startedAt: (o.now ?? (() => new Date()))().toISOString(),
|
|
37
|
+
...(o.command !== undefined ? { command: o.command } : {}),
|
|
38
|
+
};
|
|
39
|
+
fs.mkdirSync(o.home, { recursive: true });
|
|
40
|
+
for (let attempt = 0;; attempt++) {
|
|
41
|
+
try {
|
|
42
|
+
// 'wx' is O_CREAT | O_EXCL: the create is the lock, in one call.
|
|
43
|
+
fs.writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
44
|
+
return { path: file, release: () => releaseLock(file, pid) };
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err.code !== 'EEXIST')
|
|
48
|
+
throw err;
|
|
49
|
+
const holder = readLock(file);
|
|
50
|
+
// A lock with no readable pid counts as stale: nobody can show a process holds it.
|
|
51
|
+
if ((holder !== null && holder.pid !== pid && isAlive(holder.pid)) || attempt > 0) {
|
|
52
|
+
throw new LockHeldError(file, holder ?? { pid: 0, startedAt: 'unknown' });
|
|
53
|
+
}
|
|
54
|
+
fs.rmSync(file, { force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function readLock(file) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
61
|
+
if (Number.isInteger(raw.pid) && typeof raw.startedAt === 'string')
|
|
62
|
+
return raw;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// missing, half-written, or not a lock
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/** Removes the lock only if it names `pid`, or names nothing: never somebody else's. */
|
|
70
|
+
export function releaseLock(file, pid) {
|
|
71
|
+
const holder = readLock(file);
|
|
72
|
+
if (holder === null || holder.pid === pid)
|
|
73
|
+
fs.rmSync(file, { force: true });
|
|
74
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PackageManager } from './install-method.js';
|
|
2
|
+
export interface RunResult {
|
|
3
|
+
status: number | null;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
}
|
|
7
|
+
/** Runs a command to completion; never throws for a non-zero exit. Injected everywhere, for tests. */
|
|
8
|
+
export type Spawn = (cmd: string, args: string[], opts?: {
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
shell?: boolean;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}) => RunResult;
|
|
13
|
+
export declare const spawnSyncRunner: Spawn;
|
|
14
|
+
/** How long an install may take. */
|
|
15
|
+
export declare const INSTALL_TIMEOUT_MS: number;
|
|
16
|
+
export interface NpmContext {
|
|
17
|
+
spawn?: Spawn;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
execPath?: string;
|
|
20
|
+
platform?: NodeJS.Platform;
|
|
21
|
+
/** Injected for tests: does this file exist? */
|
|
22
|
+
exists?: (p: string) => boolean;
|
|
23
|
+
realpath?: (p: string) => string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* How to run the npm that belongs to this Node: `[execPath, npm-cli.js]`, found beside it (`<dir>/npm` is a symlink
|
|
27
|
+
* to it on POSIX; on Windows it's `<dir>/node_modules/npm/bin/npm-cli.js`). Falls back to `npm` on the PATH, for a
|
|
28
|
+
* distribution that packages npm separately.
|
|
29
|
+
*/
|
|
30
|
+
export declare function npmCommand(ctx?: NpmContext): {
|
|
31
|
+
cmd: string;
|
|
32
|
+
pre: string[];
|
|
33
|
+
shell: boolean;
|
|
34
|
+
};
|
|
35
|
+
/** Runs this Node's npm. */
|
|
36
|
+
export declare function runNpm(args: string[], ctx?: NpmContext, timeoutMs?: number): RunResult;
|
|
37
|
+
/** `npm root -g`, or undefined when npm can't answer. */
|
|
38
|
+
export declare function npmGlobalRoot(ctx?: NpmContext): string | undefined;
|
|
39
|
+
/** `npm config get <key>`, or undefined. */
|
|
40
|
+
export declare function npmConfigGet(key: string, ctx?: NpmContext): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Whether `dir` is really writable (SPEC §5 step 5): a folder is created in it and removed again, the same call npm
|
|
43
|
+
* is about to make. `access(W_OK)` alone says yes to root on a read-only mount, and nothing about ACLs or immutable
|
|
44
|
+
* flags. A folder that doesn't exist yet asks its nearest existing parent, since npm would create it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isWritable(dir: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Where a manager would install, for `isWritable` to ask about. Only npm can be asked (`npm root -g`). For the others
|
|
49
|
+
* there's no answer, and an install that can't write fails with that manager's own message, and rolls back.
|
|
50
|
+
*/
|
|
51
|
+
export declare function globalRootFor(manager: PackageManager, ctx?: NpmContext): string | undefined;
|
|
52
|
+
/** The global install command, per SPEC §6's last column. */
|
|
53
|
+
export declare function installArgs(manager: PackageManager, spec: string): {
|
|
54
|
+
cmd: string;
|
|
55
|
+
args: string[];
|
|
56
|
+
};
|
|
57
|
+
/** Installs `<name>@<version>` with the manager this install came from. pnpm, bun and volta come from the PATH. */
|
|
58
|
+
export declare function installPackage(o: NpmContext & {
|
|
59
|
+
manager: PackageManager;
|
|
60
|
+
packageName: string;
|
|
61
|
+
version: string;
|
|
62
|
+
}): RunResult;
|
|
63
|
+
/**
|
|
64
|
+
* `npm cache add <name>@<version>` (SPEC §5 step 8), for the target and for the current version, while the old one
|
|
65
|
+
* still serves. Caching the current one is what lets a rollback reinstall it when the network is what failed. npm
|
|
66
|
+
* is used whatever the manager: it ships with Node, and the point is only to have the tarball on the machine.
|
|
67
|
+
*/
|
|
68
|
+
export declare function cacheAdd(packageName: string, version: string, ctx?: NpmContext): RunResult;
|
|
69
|
+
/**
|
|
70
|
+
* A CLI's version, by running it: `node <cli> --version`. This is how `update` shows the install replaced the files
|
|
71
|
+
* before it hands over. A `package.json` beside it answers a different question: a stale symlink, or a pnpm layout
|
|
72
|
+
* whose versioned path moved, shows up here and not there.
|
|
73
|
+
*/
|
|
74
|
+
export declare function cliVersion(cliPath: string, ctx?: NpmContext): string | undefined;
|