@edspencer/paddock 0.81.0 → 0.82.0
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 +208 -22
- package/packages/server/dist/cli/paddock.js +51 -21
- package/packages/server/dist/cli/service/index.d.ts +52 -0
- package/packages/server/dist/cli/service/index.js +170 -3
- 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/systemd.js +27 -0
- package/packages/server/dist/cli/update/install-method.d.ts +131 -0
- package/packages/server/dist/cli/update/install-method.js +258 -0
- package/packages/server/dist/cli/update/lock.d.ts +40 -0
- package/packages/server/dist/cli/update/lock.js +108 -0
- package/packages/server/dist/cli/update/package-manager.d.ts +109 -0
- package/packages/server/dist/cli/update/package-manager.js +221 -0
- package/packages/server/dist/cli/update/registry.d.ts +89 -0
- package/packages/server/dist/cli/update/registry.js +145 -0
- package/packages/server/dist/cli/update/semver.d.ts +68 -0
- package/packages/server/dist/cli/update/semver.js +232 -0
- package/packages/server/dist/cli/update/update-check.d.ts +80 -0
- package/packages/server/dist/cli/update/update-check.js +116 -0
- package/packages/server/dist/cli/update/update.d.ts +160 -0
- package/packages/server/dist/cli/update/update.js +609 -0
- package/packages/server/dist/cli/update-command.d.ts +20 -0
- package/packages/server/dist/cli/update-command.js +113 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{DeleteProjectDialog-D8oIJ8HC.js → DeleteProjectDialog-CphdGB3R.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +2 -0
- package/packages/web/dist/assets/{DiscoverView-o0KCr3aB.js → DiscoverView-B_kcF4sm.js} +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-RSJz7oL3.js → InstanceConfigPage-CvUK5DKW.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-39mafH8b.js → MarkdownRenderer-DvZ-j_Ao.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-Bq7GVguY.js → ProjectRedirect-DayHvUH2.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-BoBIru1g.js → ProjectView-CCw83gUi.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-mJ4gqbr6.js → ProjectsGrid-B3Klg2H4.js} +1 -1
- package/packages/web/dist/assets/{RootHome-C0kwFyYp.js → RootHome-BxcDvpgI.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-MkhXlB8z.js → Surfaces-7UlhFBSG.js} +1 -1
- package/packages/web/dist/assets/{arc-DExlwAwO.js → arc-DNkh5o5Q.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CZly3W79.js → architectureDiagram-3BPJPVTR-BrgN3oLF.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-AgES73i3.js → blockDiagram-GPEHLZMM-D05XOJSk.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-N9mD8Pfk.js → c4Diagram-AAUBKEIU-CKaMBhMP.js} +1 -1
- package/packages/web/dist/assets/channel-JrEedxB8.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-Dsx_kfUl.js → chunk-2J33WTMH-8hgadCQc.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-BQjqcjPj.js → chunk-4BX2VUAB-CBfEC-pu.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CCxpYQpY.js → chunk-55IACEB6-DV1eMgHk.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-DpFRdnUa.js → chunk-727SXJPM-Cfgl5fxY.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-D2BAlzoO.js → chunk-AQP2D5EJ-BXhqD295.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BFaMRzUF.js → chunk-FMBD7UC4-TFGOPtek.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-B9NGJ9xZ.js → chunk-ND2GUHAM-CHFM665R.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CF1IHyMS.js → chunk-QZHKN3VN-CqEf6MMT.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +2 -0
- package/packages/web/dist/assets/{core-XjElsvQ7.js → core-DmEaKxBb.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CF972Flx.js → cose-bilkent-S5V4N54A-BnGI6jkr.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-DsneI8YE.js → dagre-BM42HDAG-CKoPx61E.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BHyrVzIk.js → diagram-2AECGRRQ-mMmtlBl1.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Dj4q8xEi.js → diagram-5GNKFQAL-Cdz9Mtnt.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BHe3Puv_.js → diagram-KO2AKTUF-DQxM3wYj.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-wzt6G2FE.js → diagram-LMA3HP47-BWrinPsq.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-CZMbMRJq.js → diagram-OG6HWLK6-U6MYUve2.js} +1 -1
- package/packages/web/dist/assets/{documentTitle-BwTIA8rI.js → documentTitle-DGjRXuTP.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Dtu14BL1.js → erDiagram-TEJ5UH35-xqMCsXxY.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-T34ql9j0.js → flowDiagram-I6XJVG4X-PMfy_czF.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DQLOeCXK.js → ganttDiagram-6RSMTGT7-whhYYE4N.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-Bk11zea6.js → gitGraphDiagram-PVQCEYII-C51JW4tf.js} +1 -1
- package/packages/web/dist/assets/{index-CvaWErFv.js → index-CHOtvlnO.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CsE83lu0.js → infoDiagram-5YYISTIA-SH8cr905.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DNVgA93n.js → ishikawaDiagram-YF4QCWOH-BjqaoU3U.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CLpSNjM3.js → journeyDiagram-JHISSGLW-CBmdEVrd.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BRzuXcKr.js → kanban-definition-UN3LZRKU-U8njjMLZ.js} +1 -1
- package/packages/web/dist/assets/{linear-BxOawONt.js → linear-DF66GPs1.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Dvs1QCcz.js → mermaid.core-CouJz7Ph.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Dmt7pYwF.js → mindmap-definition-RKZ34NQL-BYIf0oFt.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BHiZaJD-.js → pieDiagram-4H26LBE5-Cm8rgg09.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-U0A1noRE.js → quadrantDiagram-W4KKPZXB-C3Il5kOH.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZLsr6yi.js → requirementDiagram-4Y6WPE33-D3PE2nTe.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BTlLGILU.js → sankeyDiagram-5OEKKPKP-DdmvBM18.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DpSS_Gb1.js → sequenceDiagram-3UESZ5HK-B8M_GAIq.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-D9srE-tV.js → stateDiagram-AJRCARHV-DZkXnK1_.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BYQ5CKab.js → timeline-definition-PNZ67QCA-IHXq7rqi.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B4W2CtSV.js → vennDiagram-CIIHVFJN-Dga7tLtT.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY--XUV7GvD.js → wardley-L42UT6IY-CmcwnEJ-.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bl2VIEGY.js → wardleyDiagram-YWT4CUSO-zHuSLrD4.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Bj2H9Rcf.js → xychartDiagram-2RQKCTM6-D93MU_ic.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- 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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type CliOptions, type ServiceAction } from "../args.js";
|
|
2
2
|
import { isNpxPath, serviceDir, type ServiceSpec } from "./spec.js";
|
|
3
3
|
import { type Runner } from "./backend.js";
|
|
4
|
+
import { type UpdateSummary } from "../update/update-check.js";
|
|
4
5
|
import { type HealthAnswer, type Readiness } from "./ready.js";
|
|
5
6
|
export interface ServiceContext {
|
|
6
7
|
/** `process.platform`. */
|
|
@@ -84,6 +85,22 @@ export interface StatusReport {
|
|
|
84
85
|
cli: string | null;
|
|
85
86
|
/** Linux only: null where the platform has no such concept. */
|
|
86
87
|
linger: boolean | null;
|
|
88
|
+
/**
|
|
89
|
+
* What the last `update --check` found, from `<data-dir>/update-check.json`
|
|
90
|
+
* (SPEC §3).
|
|
91
|
+
*
|
|
92
|
+
* `status` never asks the registry itself — the contract is explicit that
|
|
93
|
+
* nothing contacts it by itself, and a status command run by a monitoring
|
|
94
|
+
* loop would be exactly the background check §3 forbids. So this repeats a
|
|
95
|
+
* file somebody's `update --check` or `doctor` wrote.
|
|
96
|
+
*
|
|
97
|
+
* **Absent, not null, when there is nothing to repeat.** The contract's
|
|
98
|
+
* `schemas/service-status.schema.json` types this key as an OBJECT, so a
|
|
99
|
+
* `null` fails the schema and the conformance runner says so — which is how
|
|
100
|
+
* this was found. The other optional fields here are `| null` because the
|
|
101
|
+
* schema types THOSE as nullable; this one it does not.
|
|
102
|
+
*/
|
|
103
|
+
update?: UpdateSummary;
|
|
87
104
|
/** A stale unit, a missing Node — things that are wrong but not fatal. */
|
|
88
105
|
warnings: string[];
|
|
89
106
|
}
|
|
@@ -94,6 +111,41 @@ export interface StatusReport {
|
|
|
94
111
|
* never `process.exit()`, which truncates piped output mid-write.
|
|
95
112
|
*/
|
|
96
113
|
export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): Promise<number>;
|
|
114
|
+
/**
|
|
115
|
+
* What `paddock update` needs to know and do about the service (SPEC §5).
|
|
116
|
+
*
|
|
117
|
+
* Built here rather than in the update module so that everything which reads a
|
|
118
|
+
* unit file stays in one place: `update` gets the same answer `status` and
|
|
119
|
+
* `logs` get, from the same backend, including the #824 rule that the instance
|
|
120
|
+
* in question is the one the UNIT describes and not whatever `--data-dir` the
|
|
121
|
+
* invocation carries.
|
|
122
|
+
*
|
|
123
|
+
* Returns `home` too, which is the data dir `update.lock` and
|
|
124
|
+
* `update-check.json` go in. It has to be the unit's: the cache's reader is
|
|
125
|
+
* `service status`, which looks in the unit's data dir, so writing it anywhere
|
|
126
|
+
* else would be writing a file nothing reads.
|
|
127
|
+
*/
|
|
128
|
+
export declare function updateServiceHooks(ctx: ServiceContext): {
|
|
129
|
+
hooks: ServiceHooksForUpdate;
|
|
130
|
+
home: string;
|
|
131
|
+
installed: boolean;
|
|
132
|
+
};
|
|
133
|
+
/** The shape {@link updateServiceHooks} returns — structurally `UpdateServiceHooks`. */
|
|
134
|
+
export interface ServiceHooksForUpdate {
|
|
135
|
+
inspect(): {
|
|
136
|
+
installed: boolean;
|
|
137
|
+
running: boolean;
|
|
138
|
+
url?: string;
|
|
139
|
+
log?: string;
|
|
140
|
+
};
|
|
141
|
+
stop(): void;
|
|
142
|
+
refresh(cliPath: string): {
|
|
143
|
+
status: number | null;
|
|
144
|
+
stdout: string;
|
|
145
|
+
stderr: string;
|
|
146
|
+
};
|
|
147
|
+
tail(lines: number): string;
|
|
148
|
+
}
|
|
97
149
|
/** Exported for the entrypoint's context assembly, and for tests. */
|
|
98
150
|
export { serviceDir, isNpxPath };
|
|
99
151
|
export type { ServiceSpec };
|
|
@@ -33,6 +33,8 @@ import { createLaunchdBackend } from "./launchd.js";
|
|
|
33
33
|
import { createSystemdBackend } from "./systemd.js";
|
|
34
34
|
import { missingPaths } from "./path-env.js";
|
|
35
35
|
import { DEFAULT_LOG_LINES, followFile, sizeOf, tailFile } from "./logs.js";
|
|
36
|
+
import { readLiveRuntimeFile } from "../../runtime-file.js";
|
|
37
|
+
import { updateSummary } from "../update/update-check.js";
|
|
36
38
|
import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
|
|
37
39
|
/**
|
|
38
40
|
* Pick a backend, or explain why there isn't one.
|
|
@@ -63,8 +65,52 @@ function resolveDataDir(opts, ctx) {
|
|
|
63
65
|
}
|
|
64
66
|
return { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined };
|
|
65
67
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
/** The value of a flag in a recorded argument vector, or undefined. */
|
|
69
|
+
function argValue(argv, names) {
|
|
70
|
+
for (let i = 0; i < argv.length; i++) {
|
|
71
|
+
if (names.includes(argv[i]) && argv[i + 1] !== undefined)
|
|
72
|
+
return argv[i + 1];
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The settings an EXISTING unit carries, as if they had been typed
|
|
78
|
+
* (`service install --refresh`, SPEC §1 and §5 step 11).
|
|
79
|
+
*
|
|
80
|
+
* This is what makes a reinstall idempotent rather than a reset. A unit
|
|
81
|
+
* installed with `--port 9000 --data-dir /srv/paddock` must come back on 9000
|
|
82
|
+
* pointing at /srv/paddock — and `update` is precisely the caller that has no
|
|
83
|
+
* way to know those values, because it runs the NEW version's CLI in a shell
|
|
84
|
+
* that never typed them.
|
|
85
|
+
*
|
|
86
|
+
* Flags still win, so `service install --refresh --port 7300` moves the port.
|
|
87
|
+
* The ENVIRONMENT deliberately does not: `resolveDataDir` normally lets
|
|
88
|
+
* `PADDOCK_DATA_DIR` stand in for `--data-dir`, which would be wrong here —
|
|
89
|
+
* the refresh is a faithful re-render of a unit, and inheriting the updating
|
|
90
|
+
* shell's data dir would silently repoint somebody's service at a different
|
|
91
|
+
* instance. A unit with no recorded `--data-dir` keeps having none, which is
|
|
92
|
+
* how it stays the same `~/.paddock` a bare `paddock` reaches.
|
|
93
|
+
*/
|
|
94
|
+
function inherit(opts, state) {
|
|
95
|
+
const port = argValue(state.argv, ["--port", "-p"]);
|
|
96
|
+
const host = argValue(state.argv, ["--host"]);
|
|
97
|
+
const dataDir = argValue(state.argv, ["--data-dir", "-d"]);
|
|
98
|
+
return {
|
|
99
|
+
...opts,
|
|
100
|
+
...(opts.port === undefined && port !== undefined ? { port } : {}),
|
|
101
|
+
...(opts.host === undefined && host !== undefined ? { host } : {}),
|
|
102
|
+
...(opts.dataDir === undefined && dataDir !== undefined ? { dataDir } : {}),
|
|
103
|
+
verbose: opts.verbose || state.argv.includes("--verbose"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function specFor(opts, ctx,
|
|
107
|
+
/** Set on a `--refresh`: the data dir comes from the unit, not the environment. */
|
|
108
|
+
fromUnit = false) {
|
|
109
|
+
const { dataDir, explicit } = fromUnit
|
|
110
|
+
? opts.dataDir !== undefined
|
|
111
|
+
? { dataDir: path.resolve(opts.dataDir), explicit: path.resolve(opts.dataDir) }
|
|
112
|
+
: { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined }
|
|
113
|
+
: resolveDataDir(opts, ctx);
|
|
68
114
|
const spec = buildSpec({
|
|
69
115
|
nodePath: ctx.nodePath,
|
|
70
116
|
scriptPath: ctx.scriptPath,
|
|
@@ -136,8 +182,27 @@ function lingerNote(linger) {
|
|
|
136
182
|
async function install(opts, ctx, backend) {
|
|
137
183
|
if (isNpxPath(ctx.packageRoot))
|
|
138
184
|
throw new CliError(NPX_REFUSAL);
|
|
139
|
-
|
|
185
|
+
// `--refresh` (SPEC §5 step 11): re-render an EXISTING unit, keeping what it
|
|
186
|
+
// records. With nothing installed there is nothing to refresh — exit 4, the
|
|
187
|
+
// same code every other command that needs a unit returns, rather than
|
|
188
|
+
// quietly performing a first install from defaults. That distinction is the
|
|
189
|
+
// whole reason the flag exists: `update` runs this through the new version's
|
|
190
|
+
// CLI, and a silent fallback would move a `--port 9000` service to 7233 on
|
|
191
|
+
// the one run nobody is watching.
|
|
192
|
+
const refreshing = opts.refresh === true;
|
|
193
|
+
let existing;
|
|
194
|
+
if (refreshing) {
|
|
195
|
+
existing = backend.status();
|
|
196
|
+
if (!existing.registered) {
|
|
197
|
+
console.log(notInstalled(backend));
|
|
198
|
+
return EXIT.notInstalled;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const { spec, dataDir } = specFor(existing !== undefined ? inherit(opts, existing) : opts, ctx, refreshing);
|
|
140
202
|
const url = `http://${hostFromArgv(spec.args)}:${portFromArgv(spec.args)}`;
|
|
203
|
+
if (refreshing) {
|
|
204
|
+
console.log(`\n Re-writing ${backend.unitPath}\n Keeping: paddock ${spec.args.join(" ")}`);
|
|
205
|
+
}
|
|
141
206
|
console.log(installProgress(backend));
|
|
142
207
|
backend.install(spec);
|
|
143
208
|
// The unit is written and the supervisor has accepted it. That is NOT the
|
|
@@ -251,6 +316,11 @@ export function installedVersionOf(cliPath) {
|
|
|
251
316
|
dir = parent;
|
|
252
317
|
}
|
|
253
318
|
}
|
|
319
|
+
/** `{ update }` when there is a recent check to repeat, and `{}` otherwise. */
|
|
320
|
+
function withUpdate(home) {
|
|
321
|
+
const summary = updateSummary(home);
|
|
322
|
+
return summary !== undefined ? { update: summary } : {};
|
|
323
|
+
}
|
|
254
324
|
async function buildStatus(ctx, backend) {
|
|
255
325
|
const state = backend.status();
|
|
256
326
|
const base = {
|
|
@@ -277,6 +347,7 @@ async function buildStatus(ctx, backend) {
|
|
|
277
347
|
node: null,
|
|
278
348
|
cli: null,
|
|
279
349
|
linger: lingerValue,
|
|
350
|
+
...withUpdate(path.resolve(ctx.envDataDir ?? defaultDataDir(ctx.homeDir))),
|
|
280
351
|
warnings: [],
|
|
281
352
|
};
|
|
282
353
|
}
|
|
@@ -318,6 +389,7 @@ async function buildStatus(ctx, backend) {
|
|
|
318
389
|
node: state.nodePath ?? null,
|
|
319
390
|
cli: state.cliPath ?? null,
|
|
320
391
|
linger: lingerValue,
|
|
392
|
+
...withUpdate(dataDir),
|
|
321
393
|
warnings,
|
|
322
394
|
};
|
|
323
395
|
}
|
|
@@ -354,6 +426,12 @@ async function status(opts, ctx, backend) {
|
|
|
354
426
|
" paddock service install register it and start it now",
|
|
355
427
|
"",
|
|
356
428
|
].join("\n"));
|
|
429
|
+
// The notice belongs here too. Found in QA: it was printed only on the
|
|
430
|
+
// installed path, so a machine with no service got `update` in `--json`
|
|
431
|
+
// and silence in the prose — the exact `--json`-disagrees-with-the-text rot
|
|
432
|
+
// that one StatusReport is supposed to prevent. A newer version is worth
|
|
433
|
+
// knowing about whether or not a unit exists.
|
|
434
|
+
noteUpdate(report);
|
|
357
435
|
return EXIT.notInstalled;
|
|
358
436
|
}
|
|
359
437
|
const versions = report.runningVersion !== null && report.runningVersion !== report.installedVersion
|
|
@@ -387,8 +465,33 @@ async function status(opts, ctx, backend) {
|
|
|
387
465
|
...report.warnings.flatMap((w) => ["", ` ⚠ ${w}`]),
|
|
388
466
|
"",
|
|
389
467
|
].join("\n"));
|
|
468
|
+
// The update notice goes to STDERR, and only ever repeats the cache (SPEC
|
|
469
|
+
// §3). stderr because it is prose about something other than what was asked
|
|
470
|
+
// for, and because that keeps every byte of stdout under the `--json`
|
|
471
|
+
// discipline's one rule rather than two.
|
|
472
|
+
noteUpdate(report);
|
|
390
473
|
return statusExit(report);
|
|
391
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Repeat the cached update answer on stderr, when there is one worth repeating
|
|
477
|
+
* (SPEC §3).
|
|
478
|
+
*
|
|
479
|
+
* Nothing is fetched here. The `Run:` line is the plain `paddock update`
|
|
480
|
+
* rather than §6's per-install variant, because working that out needs the
|
|
481
|
+
* install method and the target's tag — and `status` has deliberately not
|
|
482
|
+
* asked the registry, so it has neither. Anyone whose install cannot update
|
|
483
|
+
* itself gets the right command from `update --check`, which has.
|
|
484
|
+
*/
|
|
485
|
+
function noteUpdate(report) {
|
|
486
|
+
const update = report.update;
|
|
487
|
+
if (update === undefined || !update.updateAvailable)
|
|
488
|
+
return;
|
|
489
|
+
console.error("");
|
|
490
|
+
const have = report.runningVersion ?? report.installedVersion;
|
|
491
|
+
console.error(` ${update.latest} is available${have !== null && have !== undefined ? ` (you have ${have})` : ""}.`);
|
|
492
|
+
console.error(" Run: paddock update");
|
|
493
|
+
console.error(` Checked: ${update.checkedAt}`);
|
|
494
|
+
}
|
|
392
495
|
/** The exit code a report means (SPEC §2). */
|
|
393
496
|
function statusExit(report) {
|
|
394
497
|
if (!report.installed)
|
|
@@ -589,6 +692,70 @@ export async function runService(action, opts, ctx) {
|
|
|
589
692
|
return logs(opts, ctx, backend);
|
|
590
693
|
return status(opts, ctx, backend);
|
|
591
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* What `paddock update` needs to know and do about the service (SPEC §5).
|
|
697
|
+
*
|
|
698
|
+
* Built here rather than in the update module so that everything which reads a
|
|
699
|
+
* unit file stays in one place: `update` gets the same answer `status` and
|
|
700
|
+
* `logs` get, from the same backend, including the #824 rule that the instance
|
|
701
|
+
* in question is the one the UNIT describes and not whatever `--data-dir` the
|
|
702
|
+
* invocation carries.
|
|
703
|
+
*
|
|
704
|
+
* Returns `home` too, which is the data dir `update.lock` and
|
|
705
|
+
* `update-check.json` go in. It has to be the unit's: the cache's reader is
|
|
706
|
+
* `service status`, which looks in the unit's data dir, so writing it anywhere
|
|
707
|
+
* else would be writing a file nothing reads.
|
|
708
|
+
*/
|
|
709
|
+
export function updateServiceHooks(ctx) {
|
|
710
|
+
const backend = backendFor(ctx);
|
|
711
|
+
const first = backend.status();
|
|
712
|
+
const home = first.registered
|
|
713
|
+
? dataDirOf(first, ctx)
|
|
714
|
+
: path.resolve(ctx.envDataDir ?? defaultDataDir(ctx.homeDir));
|
|
715
|
+
const inspect = () => {
|
|
716
|
+
const state = backend.status();
|
|
717
|
+
if (!state.registered) {
|
|
718
|
+
// No unit. A foreground `paddock serve` is still worth finding, because
|
|
719
|
+
// SPEC §5's "without a service" path has to be able to say "restart
|
|
720
|
+
// Paddock to use the new version" — and `runtime.json` is the only honest
|
|
721
|
+
// way to find it (no guessing at a default port, the busy client's rule).
|
|
722
|
+
const live = readLiveRuntimeFile(home);
|
|
723
|
+
return {
|
|
724
|
+
installed: false,
|
|
725
|
+
running: live !== null,
|
|
726
|
+
...(live !== null ? { url: live.url } : {}),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
installed: true,
|
|
731
|
+
running: state.running,
|
|
732
|
+
url: urlOf(state),
|
|
733
|
+
log: logPathOf(state, dataDirOf(state, ctx)),
|
|
734
|
+
};
|
|
735
|
+
};
|
|
736
|
+
return {
|
|
737
|
+
installed: first.registered,
|
|
738
|
+
home,
|
|
739
|
+
hooks: {
|
|
740
|
+
inspect,
|
|
741
|
+
stop: () => backend.stop(),
|
|
742
|
+
// The NEW version's CLI writes the unit (SPEC §5 step 11). Run with the
|
|
743
|
+
// interpreter we are running, not the bin shim: the shim's shebang has to
|
|
744
|
+
// find `node` on PATH, and under a version manager there may not be one.
|
|
745
|
+
refresh: (cliPath) => {
|
|
746
|
+
const run = ctx.run ?? spawnRunner;
|
|
747
|
+
return run(process.execPath, [cliPath, "service", "install", "--refresh"]);
|
|
748
|
+
},
|
|
749
|
+
tail: (lines) => {
|
|
750
|
+
const state = backend.status();
|
|
751
|
+
if (!state.registered)
|
|
752
|
+
return "";
|
|
753
|
+
const file = logPathOf(state, dataDirOf(state, ctx));
|
|
754
|
+
return fs.existsSync(file) ? tailFile(file, lines) : "";
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
}
|
|
592
759
|
/** Exported for the entrypoint's context assembly, and for tests. */
|
|
593
760
|
export { serviceDir, isNpxPath };
|
|
594
761
|
/** Best-effort home dir; `os.homedir()` throws on a machine with no passwd entry. */
|
|
@@ -81,3 +81,33 @@ export declare const READY_TIMEOUT_LABEL = "20 seconds";
|
|
|
81
81
|
* would park here well past `timeoutMs`.
|
|
82
82
|
*/
|
|
83
83
|
export declare function waitForReady(opts: ReadyOptions): Promise<Readiness>;
|
|
84
|
+
/** What {@link waitForVersion} concluded (CLI contract SPEC §5 step 12). */
|
|
85
|
+
export type VersionVerdict = {
|
|
86
|
+
state: "ok";
|
|
87
|
+
version: string;
|
|
88
|
+
}
|
|
89
|
+
/** Something answers, and it is not the version we were waiting for. */
|
|
90
|
+
| {
|
|
91
|
+
state: "wrong-version";
|
|
92
|
+
version: string;
|
|
93
|
+
} | {
|
|
94
|
+
state: "no-answer";
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Poll until `/api/health` reports exactly `version` (SPEC §5 step 12).
|
|
98
|
+
*
|
|
99
|
+
* The version is the point, and it is what makes `update`'s health check mean
|
|
100
|
+
* something. "The URL answers" is satisfied by the OLD server that has not
|
|
101
|
+
* finished shutting down, by a sibling instance on the same port, and by a
|
|
102
|
+
* supervisor that restarted the old unit because the new one crashed — all
|
|
103
|
+
* three of which are exactly the failures step 13's rollback exists for. An
|
|
104
|
+
* update that polled for a 200 would call every one of them a success.
|
|
105
|
+
*
|
|
106
|
+
* The distinction between "no answer" and "the wrong version answers" is kept
|
|
107
|
+
* because the two read completely differently in a failure report: one is a
|
|
108
|
+
* server that did not come up, the other is a server that came up as something
|
|
109
|
+
* else.
|
|
110
|
+
*/
|
|
111
|
+
export declare function waitForVersion(opts: ReadyOptions & {
|
|
112
|
+
version: string;
|
|
113
|
+
}): Promise<VersionVerdict>;
|
|
@@ -99,3 +99,38 @@ export async function waitForReady(opts) {
|
|
|
99
99
|
await sleep(intervalMs);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Poll until `/api/health` reports exactly `version` (SPEC §5 step 12).
|
|
104
|
+
*
|
|
105
|
+
* The version is the point, and it is what makes `update`'s health check mean
|
|
106
|
+
* something. "The URL answers" is satisfied by the OLD server that has not
|
|
107
|
+
* finished shutting down, by a sibling instance on the same port, and by a
|
|
108
|
+
* supervisor that restarted the old unit because the new one crashed — all
|
|
109
|
+
* three of which are exactly the failures step 13's rollback exists for. An
|
|
110
|
+
* update that polled for a 200 would call every one of them a success.
|
|
111
|
+
*
|
|
112
|
+
* The distinction between "no answer" and "the wrong version answers" is kept
|
|
113
|
+
* because the two read completely differently in a failure report: one is a
|
|
114
|
+
* server that did not come up, the other is a server that came up as something
|
|
115
|
+
* else.
|
|
116
|
+
*/
|
|
117
|
+
export async function waitForVersion(opts) {
|
|
118
|
+
const { url, version, timeoutMs = READY_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS, fetchImpl = fetch, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), } = opts;
|
|
119
|
+
const deadline = now() + timeoutMs;
|
|
120
|
+
let last;
|
|
121
|
+
for (;;) {
|
|
122
|
+
const health = await fetchHealth(url, {
|
|
123
|
+
fetchImpl,
|
|
124
|
+
timeoutMs: Math.max(250, Math.min(2_000, deadline - now())),
|
|
125
|
+
});
|
|
126
|
+
if (health?.version !== undefined) {
|
|
127
|
+
if (health.version === version)
|
|
128
|
+
return { state: "ok", version };
|
|
129
|
+
last = health.version;
|
|
130
|
+
}
|
|
131
|
+
if (deadline - now() <= 0) {
|
|
132
|
+
return last !== undefined ? { state: "wrong-version", version: last } : { state: "no-answer" };
|
|
133
|
+
}
|
|
134
|
+
await sleep(intervalMs);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -224,6 +224,9 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
|
|
|
224
224
|
unitPath: file,
|
|
225
225
|
label: SYSTEMD_UNIT,
|
|
226
226
|
install(spec) {
|
|
227
|
+
// Asked BEFORE the unit is overwritten, because the answer decides whether a restart is
|
|
228
|
+
// needed below and the file is about to stop describing what is running.
|
|
229
|
+
const wasActive = fs.existsSync(file) && run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]).stdout.trim() === "active";
|
|
227
230
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
228
231
|
fs.mkdirSync(spec.workingDirectory, { recursive: true });
|
|
229
232
|
// systemd creates the log FILE for `append:` but not its directory, and a
|
|
@@ -239,6 +242,30 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
|
|
|
239
242
|
const enabled = run("systemctl", args);
|
|
240
243
|
if (enabled.status !== 0)
|
|
241
244
|
throw new Error(systemctlFailure(args, enabled));
|
|
245
|
+
// And then RESTART, but only if it was ALREADY active, because `enable --now` does nothing
|
|
246
|
+
// to a unit that is already running — systemd starts it only if it is stopped. So a
|
|
247
|
+
// reinstall over a running service wrote a new unit, left the old process serving, and
|
|
248
|
+
// reported success.
|
|
249
|
+
//
|
|
250
|
+
// That is not a theoretical gap: it is the one `paddock update`'s ROLLBACK walks into. The
|
|
251
|
+
// forward path stops the service first (SPEC §5 step 9), so `enable --now` starts the new
|
|
252
|
+
// version cleanly; the rollback runs `service install --refresh` while the FAILED version
|
|
253
|
+
// is still up, and without this the "restored" service was the broken one — which turns a
|
|
254
|
+
// clean exit 6 into an exit 7 and a machine nobody has fixed.
|
|
255
|
+
//
|
|
256
|
+
// Conditional rather than unconditional, even though `restart` would converge both states
|
|
257
|
+
// in one call: on a stopped unit that would be TWO starts milliseconds apart, and systemd's
|
|
258
|
+
// default start limit is five in ten seconds. Halving the headroom of a unit whose whole
|
|
259
|
+
// job is to come back is not worth saving a question.
|
|
260
|
+
//
|
|
261
|
+
// launchd needs no equivalent — its install is a bootout followed by a bootstrap, which
|
|
262
|
+
// already replaces a running job.
|
|
263
|
+
if (wasActive) {
|
|
264
|
+
const restartArgs = ["--user", "restart", SYSTEMD_UNIT];
|
|
265
|
+
const restarted = run("systemctl", restartArgs);
|
|
266
|
+
if (restarted.status !== 0)
|
|
267
|
+
throw new Error(systemctlFailure(restartArgs, restarted));
|
|
268
|
+
}
|
|
242
269
|
},
|
|
243
270
|
uninstall() {
|
|
244
271
|
const existed = fs.existsSync(file);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** The ten rows of SPEC §6's table, 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, e.g. `@edspencer/paddock`. */
|
|
12
|
+
packageName: string;
|
|
13
|
+
/** The environment prefix: `PADDOCK` gives `PADDOCK_IN_DOCKER` and `PADDOCK_UPDATE_HINT`. */
|
|
14
|
+
envPrefix: string;
|
|
15
|
+
/**
|
|
16
|
+
* The script to walk up from — `process.argv[1]`, or this module's own file.
|
|
17
|
+
*
|
|
18
|
+
* SPEC §6 says `realpath(process.argv[1])`. Realpathing is done HERE rather
|
|
19
|
+
* than asked of the caller, because npm installs a `bin` as a symlink and the
|
|
20
|
+
* whole table is about where the package really sits (args.ts has the long
|
|
21
|
+
* version of why that distinction has bitten this CLI before).
|
|
22
|
+
*/
|
|
23
|
+
entry: string;
|
|
24
|
+
env: NodeJS.ProcessEnv;
|
|
25
|
+
/**
|
|
26
|
+
* `npm root -g`, asked of the `npm` next to `process.execPath` (SPEC §6).
|
|
27
|
+
*
|
|
28
|
+
* Injected because it spawns a process: a unit test of the table must not
|
|
29
|
+
* depend on what the machine running it happens to have installed globally.
|
|
30
|
+
* Returning undefined (no npm, or it failed) is a normal answer — the row
|
|
31
|
+
* simply does not match.
|
|
32
|
+
*/
|
|
33
|
+
npmGlobalRoot?: () => string | undefined;
|
|
34
|
+
fs?: FsLike;
|
|
35
|
+
}
|
|
36
|
+
export interface InstallInfo {
|
|
37
|
+
method: InstallMethod;
|
|
38
|
+
/**
|
|
39
|
+
* The folder whose `package.json` names {@link DetectInput.packageName}, or —
|
|
40
|
+
* when no ancestor does — the nearest folder with a `package.json` at all.
|
|
41
|
+
*
|
|
42
|
+
* The fallback is what makes a checkout detectable. Paddock's repo publishes
|
|
43
|
+
* a SYNTHESIZED package (`scripts/make-npm-package.mjs`), so in the checkout
|
|
44
|
+
* no manifest is called `@edspencer/paddock`: the root one is `paddock` and
|
|
45
|
+
* the workspace one is `@paddock/server`. A strict search would find nothing
|
|
46
|
+
* and have nothing to say; the nearest root finds `packages/server` and the
|
|
47
|
+
* "not under node_modules" test then correctly reports a checkout.
|
|
48
|
+
*/
|
|
49
|
+
packageRoot: string | undefined;
|
|
50
|
+
/** True when the name was actually found, rather than fallen back to. */
|
|
51
|
+
named: boolean;
|
|
52
|
+
/** Can `update` install a new version itself, or must it print a command and exit 5? */
|
|
53
|
+
canUpdate: boolean;
|
|
54
|
+
/** The package manager to install with, when it can. */
|
|
55
|
+
manager?: PackageManager;
|
|
56
|
+
/** `<TOOL>_UPDATE_HINT`, when the environment set one. */
|
|
57
|
+
hint?: string;
|
|
58
|
+
}
|
|
59
|
+
/** `PADDOCK` → `PADDOCK_IN_DOCKER`. */
|
|
60
|
+
export declare const inDockerVar: (prefix: string) => string;
|
|
61
|
+
/** `PADDOCK` → `PADDOCK_UPDATE_HINT`. */
|
|
62
|
+
export declare const updateHintVar: (prefix: string) => string;
|
|
63
|
+
/**
|
|
64
|
+
* Walk up from `entry` looking for the package root.
|
|
65
|
+
*
|
|
66
|
+
* Returns the first ancestor whose `package.json` has our name, and the nearest
|
|
67
|
+
* ancestor with any `package.json` as a fallback — see
|
|
68
|
+
* {@link InstallInfo.packageRoot}.
|
|
69
|
+
*/
|
|
70
|
+
export declare function findPackageRoot(entry: string, packageName: string, fs: FsLike): {
|
|
71
|
+
root: string | undefined;
|
|
72
|
+
named: boolean;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Is `p` inside a `node_modules` directory?
|
|
76
|
+
*
|
|
77
|
+
* Split on the separator rather than matched as a substring, so a project
|
|
78
|
+
* genuinely called `my-node_modules` is not mistaken for one (and so Windows
|
|
79
|
+
* paths work, since `path.sep` is what split them). SPEC §6's "checkout or npm
|
|
80
|
+
* link" row is exactly the negation of this.
|
|
81
|
+
*/
|
|
82
|
+
export declare function underNodeModules(p: string): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* The npx / pnpm dlx / bunx / yarn dlx caches (SPEC §6).
|
|
85
|
+
*
|
|
86
|
+
* `_npx` and `bunx-` are matched between separators for the reason above.
|
|
87
|
+
* `dlx` has to be looser — pnpm's is `~/.local/share/pnpm/store/.../dlx/<hash>`
|
|
88
|
+
* and yarn's is `$TMPDIR/xfs-<hash>/dlx-<n>` — so a path SEGMENT that is `dlx`
|
|
89
|
+
* or starts `dlx-` counts.
|
|
90
|
+
*/
|
|
91
|
+
export declare function isEphemeralCachePath(p: string): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Which of SPEC §6's rows this install is, checked in the table's own order.
|
|
94
|
+
*
|
|
95
|
+
* The order is the contract, and two pairs of rows genuinely overlap:
|
|
96
|
+
*
|
|
97
|
+
* - **Docker beats managed.** A container that an Ansible role also labelled
|
|
98
|
+
* has both variables set, and the honest answer is "this is an image" — but
|
|
99
|
+
* the hint is still carried on {@link InstallInfo.hint} and
|
|
100
|
+
* {@link updateCommand} prefers it, because a hint is a sentence an operator
|
|
101
|
+
* deliberately wrote and our guess at a `docker compose` line is not.
|
|
102
|
+
* - **Checkout beats every global.** They cannot both be true (every global
|
|
103
|
+
* layout is under a `node_modules`), so the order between them never decides
|
|
104
|
+
* anything; it is kept as the table has it so the two can be read side by
|
|
105
|
+
* side.
|
|
106
|
+
*/
|
|
107
|
+
export declare function detectInstall(input: DetectInput): InstallInfo;
|
|
108
|
+
export interface CommandInput {
|
|
109
|
+
packageName: string;
|
|
110
|
+
/** The version to install, when one has been resolved. */
|
|
111
|
+
target?: string;
|
|
112
|
+
/** The container image to pull, for the Docker row: `ghcr.io/edspencer/paddock`. */
|
|
113
|
+
image?: string;
|
|
114
|
+
/** Prefix the npm command with `sudo` — SPEC §5 step 5's not-writable case. */
|
|
115
|
+
sudo?: boolean;
|
|
116
|
+
/** The command that brings a checkout up to date. */
|
|
117
|
+
checkoutCommand?: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The one command to print (SPEC §3, §6): what the reader should run instead.
|
|
121
|
+
*
|
|
122
|
+
* Returns the command and, where it helps, the parenthetical the spec's example
|
|
123
|
+
* carries (`(the global folder isn't writable)`). An install that CAN update
|
|
124
|
+
* itself gets `<tool> update`, which is the line the update notice prints.
|
|
125
|
+
*/
|
|
126
|
+
export declare function updateCommand(info: InstallInfo, input: CommandInput): {
|
|
127
|
+
command: string;
|
|
128
|
+
note?: string;
|
|
129
|
+
};
|
|
130
|
+
/** One line a person can act on, for every row of the table. */
|
|
131
|
+
export declare function describeInstall(info: InstallInfo, packageName: string): string;
|