@timqi/pier 0.0.20 → 0.0.22
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/dist/agent/config-sync.js +124 -92
- package/dist/config-sync-fetch.js +66 -79
- package/dist/db.js +5 -3
- package/dist/main.js +11 -4
- package/dist/service.js +17 -7
- package/dist/tasks/definitions.js +4 -0
- package/dist/tasks/tool.js +70 -13
- package/dist/web/instance.js +3 -3
- package/dist/web/public/assets/activity-Bv8fc5Wj.js +5 -0
- package/dist/web/public/assets/activity-Bv8fc5Wj.js.br +0 -0
- package/dist/web/public/assets/activity-Bv8fc5Wj.js.gz +0 -0
- package/dist/web/public/assets/boards-BRKjgEYX.js +1 -0
- package/dist/web/public/assets/boards-BRKjgEYX.js.br +0 -0
- package/dist/web/public/assets/boards-BRKjgEYX.js.gz +0 -0
- package/dist/web/public/assets/{code-p-Tq1bMK.js → code-BgAXjUH3.js} +1 -1
- package/dist/web/public/assets/{explorer-50no_DrD.js → explorer-Cq_Ertso.js} +3 -3
- package/dist/web/public/assets/explorer-Cq_Ertso.js.br +0 -0
- package/dist/web/public/assets/explorer-Cq_Ertso.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-Bvhh3ZFk.js → ghostty-web-CZHJ0YFi.js} +1 -1
- package/dist/web/public/assets/ghostty-web-CZHJ0YFi.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-CZHJ0YFi.js.gz +0 -0
- package/dist/web/public/assets/index-BH7ir5I4.css +2 -0
- package/dist/web/public/assets/index-BH7ir5I4.css.br +0 -0
- package/dist/web/public/assets/index-BH7ir5I4.css.gz +0 -0
- package/dist/web/public/assets/index-BrBNzaid.js +79 -0
- package/dist/web/public/assets/index-BrBNzaid.js.br +0 -0
- package/dist/web/public/assets/index-BrBNzaid.js.gz +0 -0
- package/dist/web/public/assets/runs-CvdNDaw8.js +1 -0
- package/dist/web/public/assets/runs-CvdNDaw8.js.br +0 -0
- package/dist/web/public/assets/runs-CvdNDaw8.js.gz +0 -0
- package/dist/web/public/assets/settings-BaVwkUUD.js +6 -0
- package/dist/web/public/assets/settings-BaVwkUUD.js.br +0 -0
- package/dist/web/public/assets/settings-BaVwkUUD.js.gz +0 -0
- package/dist/web/public/assets/task-runs-B-eoldcU.js +3 -0
- package/dist/web/public/assets/task-runs-B-eoldcU.js.br +0 -0
- package/dist/web/public/assets/task-runs-B-eoldcU.js.gz +0 -0
- package/dist/web/public/assets/tasks-CT4LECPQ.js +4 -0
- package/dist/web/public/assets/tasks-CT4LECPQ.js.br +0 -0
- package/dist/web/public/assets/tasks-CT4LECPQ.js.gz +0 -0
- package/dist/web/public/assets/terminal-C0qFDFou.js +2 -0
- package/dist/web/public/assets/terminal-C0qFDFou.js.br +0 -0
- package/dist/web/public/assets/terminal-C0qFDFou.js.gz +0 -0
- package/dist/web/public/index.html +16 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/docs/deploy.md +20 -8
- package/package.json +1 -1
- package/skills/pier-tasks/SKILL.md +20 -11
- package/dist/web/public/assets/activity-DYo7QS7k.js +0 -5
- package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js +0 -1
- package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css +0 -2
- package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js +0 -79
- package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js +0 -1
- package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js +0 -6
- package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js +0 -3
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js +0 -4
- package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js +0 -2
- package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
|
@@ -1,54 +1,98 @@
|
|
|
1
1
|
// Credential-blind projection of the portable agent configuration.
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/** Never leaves the instance, at any depth of a models.json provider: the
|
|
3
|
+
* credentials, and the endpoint they authenticate against — a sharing link is
|
|
4
|
+
* public. Every other field is metadata Pi's own schema already validates, so
|
|
5
|
+
* it travels as-is instead of through a field list chasing Pi's versions. */
|
|
6
|
+
const PRIVATE_KEYS = ["apiKey", "headers", "baseUrl"];
|
|
7
|
+
/** The catalog is the part of a provider sync owns outright: absent on the
|
|
8
|
+
* source means deleted here, while a field the source never states stays. */
|
|
9
|
+
const CATALOG_KEYS = ["models", "modelOverrides"];
|
|
10
|
+
/** Dropped strings shorter than this ("", "Bearer") collide with legitimate
|
|
11
|
+
* metadata; a credential or an endpoint does not. */
|
|
12
|
+
const SECRET_CHARS = 8;
|
|
13
|
+
const asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
4
14
|
function record(value, where) {
|
|
5
|
-
|
|
6
|
-
|
|
15
|
+
const plain = asRecord(value);
|
|
16
|
+
if (!plain || ![Object.prototype, null].includes(Object.getPrototypeOf(plain))) {
|
|
7
17
|
throw new Error(`${where} must be an object`);
|
|
8
18
|
}
|
|
9
|
-
return
|
|
19
|
+
return plain;
|
|
10
20
|
}
|
|
11
21
|
function only(value, keys, where) {
|
|
12
22
|
if (Object.keys(value).some((key) => !keys.includes(key))) {
|
|
13
23
|
throw new Error(`${where} contains unsupported fields`);
|
|
14
24
|
}
|
|
15
25
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/** Every string in a JSON value, so one containment test covers the whole
|
|
27
|
+
* projection however deeply Pi nests a future field. */
|
|
28
|
+
function strings(value, found = []) {
|
|
29
|
+
const nested = asRecord(value);
|
|
30
|
+
if (typeof value === "string")
|
|
31
|
+
found.push(value);
|
|
32
|
+
else if (Array.isArray(value))
|
|
33
|
+
for (const entry of value)
|
|
34
|
+
strings(entry, found);
|
|
35
|
+
else if (nested)
|
|
36
|
+
for (const entry of Object.values(nested))
|
|
37
|
+
strings(entry, found);
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
/** Deep copy of JSON-only data; the private fields are dropped and handed to
|
|
41
|
+
* `onPrivate` — collected on the way out, refused on the way in. */
|
|
42
|
+
function project(value, where, onPrivate) {
|
|
43
|
+
if (value === null || ["string", "boolean", "number"].includes(typeof value))
|
|
44
|
+
return value;
|
|
45
|
+
if (Array.isArray(value))
|
|
46
|
+
return value.map((entry) => project(entry, where, onPrivate));
|
|
47
|
+
return Object.fromEntries(Object.entries(record(value, where)).flatMap(([key, entry]) => {
|
|
48
|
+
if (PRIVATE_KEYS.includes(key)) {
|
|
49
|
+
onPrivate(key, entry);
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return [[key, project(entry, `${where} ${key}`, onPrivate)]];
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
/** The shape both sides must agree on: a provider id that cannot address
|
|
56
|
+
* anything but its own record, and a catalog addressable by model id. */
|
|
57
|
+
function portable(id, value, onPrivate) {
|
|
58
|
+
if (id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || ["constructor", "prototype"].includes(id)) {
|
|
59
|
+
throw new Error("invalid snapshot provider id");
|
|
29
60
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
throw new Error("
|
|
61
|
+
const provider = record(project(value, `provider ${id}`, onPrivate), `provider ${id}`);
|
|
62
|
+
const models = provider.models ?? [];
|
|
63
|
+
if (!Array.isArray(models))
|
|
64
|
+
throw new Error("models must be an array");
|
|
65
|
+
const ids = models.map((model) => record(model, "model").id);
|
|
66
|
+
if (ids.some((modelId) => typeof modelId !== "string" || !modelId.trim() || modelId !== modelId.trim())) {
|
|
67
|
+
throw new Error("model id must be a non-empty trimmed string");
|
|
34
68
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
69
|
+
if (new Set(ids).size !== ids.length)
|
|
70
|
+
throw new Error("duplicate model ids");
|
|
71
|
+
const overrides = provider.modelOverrides ?? {};
|
|
72
|
+
for (const [overrideId, override] of Object.entries(record(overrides, "modelOverrides"))) {
|
|
73
|
+
record(override, `modelOverrides ${overrideId}`);
|
|
39
74
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
75
|
+
return provider;
|
|
76
|
+
}
|
|
77
|
+
/** Project disk metadata before validation, then fail closed if anything the
|
|
78
|
+
* projection removed still appears in it: a deny list cannot foresee the field
|
|
79
|
+
* Pi adds next, but it can refuse to publish a value it just dropped. */
|
|
80
|
+
export function snapshotProviders(providers = {}) {
|
|
81
|
+
const dropped = [];
|
|
82
|
+
const shared = Object.fromEntries(Object.entries(providers).flatMap(([id, provider]) => {
|
|
83
|
+
const value = portable(id, provider, (_key, entry) => {
|
|
84
|
+
dropped.push(...strings(entry).filter((secret) => secret.length >= SECRET_CHARS));
|
|
85
|
+
});
|
|
86
|
+
return Object.keys(value).length ? [[id, value]] : [];
|
|
87
|
+
}));
|
|
88
|
+
const exported = strings(shared);
|
|
89
|
+
if (dropped.some((secret) => exported.some((value) => value.includes(secret)))) {
|
|
90
|
+
throw new Error("models.json repeats a credential or endpoint in a shared field; remove it before sharing");
|
|
48
91
|
}
|
|
49
|
-
return
|
|
92
|
+
return shared;
|
|
50
93
|
}
|
|
51
|
-
/** Strict import boundary:
|
|
94
|
+
/** Strict import boundary: a source that states a private field is refused,
|
|
95
|
+
* never trusted as a mask over the local one. */
|
|
52
96
|
export function normalizeAgentSnapshot(raw) {
|
|
53
97
|
const snapshot = record(raw, "agent snapshot");
|
|
54
98
|
only(snapshot, ["files", "providers"], "agent snapshot");
|
|
@@ -59,65 +103,53 @@ export function normalizeAgentSnapshot(raw) {
|
|
|
59
103
|
throw new Error(`${name} must be a string or null`);
|
|
60
104
|
}
|
|
61
105
|
}
|
|
62
|
-
const providers = Object.fromEntries(Object.entries(record(snapshot.providers, "snapshot providers"))
|
|
63
|
-
|
|
64
|
-
throw new Error(
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const models = provider.models.map(modelDefinition);
|
|
71
|
-
if (new Set(models.map((model) => model.id)).size !== models.length)
|
|
72
|
-
throw new Error("duplicate model ids");
|
|
73
|
-
return [id, { models }];
|
|
74
|
-
}));
|
|
75
|
-
return { files: { "SYSTEM.md": files["SYSTEM.md"], "AGENTS.md": files["AGENTS.md"] }, providers };
|
|
106
|
+
const providers = Object.fromEntries(Object.entries(record(snapshot.providers, "snapshot providers"))
|
|
107
|
+
.map(([id, provider]) => [id, portable(id, provider, (key) => {
|
|
108
|
+
throw new Error(`snapshot provider must not carry ${key}`);
|
|
109
|
+
})]));
|
|
110
|
+
return {
|
|
111
|
+
files: { "SYSTEM.md": files["SYSTEM.md"], "AGENTS.md": files["AGENTS.md"] },
|
|
112
|
+
providers,
|
|
113
|
+
};
|
|
76
114
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const models = provider.models.map((value) => {
|
|
85
|
-
const source = record(value, "models.json model");
|
|
86
|
-
const model = Object.fromEntries(MODEL_KEYS.flatMap((key) => source[key] === undefined ? [] : [[key, source[key]]]));
|
|
87
|
-
if (source.cost !== undefined) {
|
|
88
|
-
const cost = record(source.cost, "models.json model cost");
|
|
89
|
-
model.cost = Object.fromEntries(COST_KEYS.flatMap((key) => cost[key] === undefined ? [] : [[key, cost[key]]]));
|
|
90
|
-
}
|
|
91
|
-
return modelDefinition(model);
|
|
92
|
-
});
|
|
93
|
-
return [[id, { models }]];
|
|
94
|
-
}));
|
|
115
|
+
/** A model or override arrives whole; only the local private fields survive it. */
|
|
116
|
+
function withLocalPrivate(incoming, local) {
|
|
117
|
+
const merged = { ...incoming };
|
|
118
|
+
for (const key of PRIVATE_KEYS)
|
|
119
|
+
if (local && Object.hasOwn(local, key))
|
|
120
|
+
merged[key] = local[key];
|
|
121
|
+
return merged;
|
|
95
122
|
}
|
|
96
|
-
|
|
123
|
+
const modelsById = (models) => new Map((Array.isArray(models) ? models : []).flatMap((model) => {
|
|
124
|
+
const value = asRecord(model);
|
|
125
|
+
return value ? [[value.id, value]] : [];
|
|
126
|
+
}));
|
|
127
|
+
/** The source sets what it states and replaces the catalog; credentials and
|
|
128
|
+
* endpoints stay local, matched by provider, model id and override id. */
|
|
97
129
|
export function mergeSnapshotProviders(local = {}, incoming) {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
if (!Object.hasOwn(incoming, id)) {
|
|
101
|
-
delete provider.models;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
for (const [id, { models }] of Object.entries(incoming)) {
|
|
105
|
-
if (!Object.hasOwn(local, id)) {
|
|
130
|
+
for (const id of Object.keys(incoming)) {
|
|
131
|
+
if (!Object.hasOwn(local, id))
|
|
106
132
|
throw new Error(`provider ${id} must be configured locally before importing models`);
|
|
107
|
-
}
|
|
108
|
-
const provider = providers[id];
|
|
109
|
-
if (provider.models !== undefined && !Array.isArray(provider.models))
|
|
110
|
-
throw new Error("models.json models must be an array");
|
|
111
|
-
const existing = new Map((provider.models ?? []).map((value) => {
|
|
112
|
-
const model = record(value, "models.json model");
|
|
113
|
-
return [model.id, model];
|
|
114
|
-
}));
|
|
115
|
-
provider.models = models.map((model) => {
|
|
116
|
-
const retained = { ...existing.get(model.id) };
|
|
117
|
-
for (const key of MODEL_KEYS)
|
|
118
|
-
delete retained[key];
|
|
119
|
-
return { ...retained, ...model };
|
|
120
|
-
});
|
|
121
133
|
}
|
|
122
|
-
return
|
|
134
|
+
return Object.fromEntries(Object.entries(local).map(([id, provider]) => {
|
|
135
|
+
const current = record(provider, "models.json provider");
|
|
136
|
+
const source = incoming[id];
|
|
137
|
+
if (!source)
|
|
138
|
+
return [id, Object.fromEntries(Object.entries(current).filter(([key]) => !CATALOG_KEYS.includes(key)))];
|
|
139
|
+
// The catalog shapes are the ones normalizeAgentSnapshot already proved.
|
|
140
|
+
const merged = { ...current, ...structuredClone(source) };
|
|
141
|
+
for (const key of CATALOG_KEYS)
|
|
142
|
+
if (!Object.hasOwn(source, key))
|
|
143
|
+
delete merged[key];
|
|
144
|
+
if (merged.models !== undefined) {
|
|
145
|
+
const previous = modelsById(current.models);
|
|
146
|
+
merged.models = merged.models.map((model) => withLocalPrivate(model, previous.get(model.id)));
|
|
147
|
+
}
|
|
148
|
+
if (merged.modelOverrides !== undefined) {
|
|
149
|
+
const previous = asRecord(current.modelOverrides) ?? {};
|
|
150
|
+
merged.modelOverrides = Object.fromEntries(Object.entries(merged.modelOverrides)
|
|
151
|
+
.map(([key, override]) => [key, withLocalPrivate(override, asRecord(previous[key]))]));
|
|
152
|
+
}
|
|
153
|
+
return [id, merged];
|
|
154
|
+
}));
|
|
123
155
|
}
|
|
@@ -1,26 +1,8 @@
|
|
|
1
|
-
// Fetch one configuration document
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
import { lookup } from "node:dns/promises";
|
|
5
|
-
import { request } from "node:https";
|
|
6
|
-
import { BlockList, isIP } from "node:net";
|
|
7
|
-
import { checkServerIdentity } from "node:tls";
|
|
1
|
+
// Fetch one configuration document from the operator's source URL: HTTPS with
|
|
2
|
+
// no credentials, redirects followed while they stay HTTPS, JSON capped at
|
|
3
|
+
// 1 MiB so a hostile or broken source cannot exhaust memory.
|
|
8
4
|
export const CONFIG_SYNC_BYTES = 1024 * 1024;
|
|
9
|
-
const
|
|
10
|
-
const blockedV6 = new BlockList();
|
|
11
|
-
for (const [address, prefix] of [
|
|
12
|
-
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
|
13
|
-
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16],
|
|
14
|
-
["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
15
|
-
])
|
|
16
|
-
blockedV4.addSubnet(address, prefix, "ipv4");
|
|
17
|
-
for (const [address, prefix] of [
|
|
18
|
-
["::", 96], ["::ffff:0:0", 96], ["64:ff9b::", 96], ["64:ff9b:1::", 48],
|
|
19
|
-
["100::", 64], ["2001::", 32], ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
|
|
20
|
-
])
|
|
21
|
-
blockedV6.addSubnet(address, prefix, "ipv6");
|
|
22
|
-
const globalV6 = new BlockList();
|
|
23
|
-
globalV6.addSubnet("2000::", 3, "ipv6");
|
|
5
|
+
const MAX_REDIRECTS = 5;
|
|
24
6
|
export function configSourceUrl(raw) {
|
|
25
7
|
let url;
|
|
26
8
|
try {
|
|
@@ -34,64 +16,69 @@ export function configSourceUrl(raw) {
|
|
|
34
16
|
}
|
|
35
17
|
return url;
|
|
36
18
|
}
|
|
37
|
-
export function publicConfigAddress(address) {
|
|
38
|
-
const family = isIP(address);
|
|
39
|
-
return family !== 0 && (family !== 6 || globalV6.check(address, "ipv6")) &&
|
|
40
|
-
!(family === 4 ? blockedV4 : blockedV6).check(address, family === 4 ? "ipv4" : "ipv6");
|
|
41
|
-
}
|
|
42
19
|
export async function downloadConfig(raw, etag, signal) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
hostname: address.address, family: address.family, port: url.port || 443,
|
|
64
|
-
servername: isIP(hostname) ? undefined : hostname,
|
|
65
|
-
checkServerIdentity: (_host, cert) => checkServerIdentity(hostname, cert),
|
|
66
|
-
path: `${url.pathname}${url.search}`, method: "GET", signal,
|
|
67
|
-
headers: { host: url.host, accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
|
|
68
|
-
}, (res) => {
|
|
69
|
-
const status = res.statusCode;
|
|
70
|
-
if (status !== 200 && status !== 304) {
|
|
71
|
-
res.destroy();
|
|
72
|
-
reject(new Error(status === 404 || status === 410
|
|
73
|
-
? "Source link was revoked or does not exist"
|
|
74
|
-
: `Source returned HTTP ${String(status)}`));
|
|
75
|
-
return;
|
|
20
|
+
let url = configSourceUrl(raw);
|
|
21
|
+
for (let hop = 0;; hop++) {
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await fetch(url, {
|
|
26
|
+
method: "GET", redirect: "manual", signal,
|
|
27
|
+
headers: { accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source");
|
|
32
|
+
}
|
|
33
|
+
const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
|
|
34
|
+
if (location) {
|
|
35
|
+
await res.body?.cancel().catch(() => { });
|
|
36
|
+
if (hop >= MAX_REDIRECTS)
|
|
37
|
+
throw new Error("Source redirected too many times");
|
|
38
|
+
try {
|
|
39
|
+
url = configSourceUrl(new URL(location, url).href);
|
|
76
40
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
reject(new Error("Source did not return JSON"));
|
|
80
|
-
return;
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error("Source redirected to a location that is not HTTPS");
|
|
81
43
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
res.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (res.status !== 200 && res.status !== 304) {
|
|
47
|
+
await res.body?.cancel().catch(() => { });
|
|
48
|
+
throw new Error(res.status === 404 || res.status === 410
|
|
49
|
+
? "Source link was revoked or does not exist"
|
|
50
|
+
: `Source returned HTTP ${String(res.status)}`);
|
|
51
|
+
}
|
|
52
|
+
const status = res.status === 304 ? 304 : 200;
|
|
53
|
+
if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
|
|
54
|
+
await res.body?.cancel().catch(() => { });
|
|
55
|
+
throw new Error("Source did not return JSON");
|
|
56
|
+
}
|
|
57
|
+
return { status, etag: res.headers.get("etag"), body: await read(res, signal) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function read(res, signal) {
|
|
61
|
+
const reader = res.body?.getReader();
|
|
62
|
+
if (!reader)
|
|
63
|
+
return "";
|
|
64
|
+
const chunks = [];
|
|
65
|
+
let size = 0;
|
|
66
|
+
try {
|
|
67
|
+
for (;;) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done)
|
|
70
|
+
break;
|
|
71
|
+
size += value.length;
|
|
72
|
+
if (size > CONFIG_SYNC_BYTES)
|
|
73
|
+
throw new Error("Configuration exceeds 1 MiB");
|
|
74
|
+
chunks.push(Buffer.from(value));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
await reader.cancel().catch(() => { });
|
|
79
|
+
if (err instanceof Error && err.message === "Configuration exceeds 1 MiB")
|
|
80
|
+
throw err;
|
|
81
|
+
throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Configuration download failed");
|
|
82
|
+
}
|
|
83
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
97
84
|
}
|
package/dist/db.js
CHANGED
|
@@ -361,9 +361,11 @@ let shared;
|
|
|
361
361
|
* defaults to it; a test passes `openDb(":memory:")` instead.
|
|
362
362
|
*/
|
|
363
363
|
export const pierDb = () => (shared ??= openDb(PIER_DB));
|
|
364
|
-
/** A release-level restore point, taken
|
|
365
|
-
*
|
|
366
|
-
*
|
|
364
|
+
/** A release-level restore point, taken for every release even when it has no
|
|
365
|
+
* schema migration. The previous complete copies stay put if writing this one
|
|
366
|
+
* fails, and the service may be running while it is written: `copyDatabase`
|
|
367
|
+
* reads through a read-only connection, so what it writes is one consistent
|
|
368
|
+
* snapshot of a live database rather than a torn `cp`.
|
|
367
369
|
*
|
|
368
370
|
* `version` is the Pier that produced this database, not the one being
|
|
369
371
|
* installed: the updater runs this from the tree it is about to replace, and
|
package/dist/main.js
CHANGED
|
@@ -205,8 +205,9 @@ await configurationSync.reconcile();
|
|
|
205
205
|
tasks.start();
|
|
206
206
|
readyForConfigReload = true;
|
|
207
207
|
void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
|
|
208
|
-
// Replacing Pier is systemd's job, not this process's: the oneshot unit
|
|
209
|
-
//
|
|
208
|
+
// Replacing Pier is systemd's job, not this process's: the oneshot unit
|
|
209
|
+
// snapshots the database, installs, then stops the service and starts it again
|
|
210
|
+
// on the new version — in that order, so only the last two are downtime. Without
|
|
210
211
|
// that unit there is nothing to hand the work to, and the Console says so
|
|
211
212
|
// instead of offering a button that cannot work.
|
|
212
213
|
const updates = new UpdateCheck();
|
|
@@ -247,8 +248,14 @@ const takeWorkAgain = (why) => {
|
|
|
247
248
|
};
|
|
248
249
|
/** How long the handover has to actually stop us. `systemctl start --no-block`
|
|
249
250
|
* returns when the job is *queued*, so "started" is not proof of anything;
|
|
250
|
-
* the real outcome is a SIGTERM
|
|
251
|
-
|
|
251
|
+
* the real outcome is a SIGTERM — but only after the updater has snapshotted
|
|
252
|
+
* the database and installed the new version, which is a registry download on
|
|
253
|
+
* someone else's network — ten seconds on a good day, minutes on a bad one.
|
|
254
|
+
* So this waits far longer than the stop itself needs, because reopening the
|
|
255
|
+
* gate mid-install would take work we are about to be SIGTERM'd out of, and
|
|
256
|
+
* still short enough that a handover which never happens does not refuse
|
|
257
|
+
* messages all afternoon. */
|
|
258
|
+
const HANDOVER_GRACE_MS = 5 * 60_000;
|
|
252
259
|
const handOverToUpdater = async () => {
|
|
253
260
|
// One handover at a time, and never on top of a restart: the Console button,
|
|
254
261
|
// the auto-update tick and SIGUSR2 would otherwise drain the same Pier
|
package/dist/service.js
CHANGED
|
@@ -104,8 +104,14 @@ function legacyOptions(home) {
|
|
|
104
104
|
...(pierHome ? { pierHome } : {}),
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
-
/** A separate cgroup
|
|
108
|
-
*
|
|
107
|
+
/** A separate cgroup snapshots the database, updates the exact npm
|
|
108
|
+
* installation recorded at install time, and only then restarts Pier.
|
|
109
|
+
*
|
|
110
|
+
* Both slow steps run while the service is still up, so the downtime is one
|
|
111
|
+
* stop and one start rather than the ~10s an install takes. The snapshot is
|
|
112
|
+
* consistent on a live database — `VACUUM INTO` off a read-only connection
|
|
113
|
+
* (db.ts) — and it still runs from the tree npm is about to replace, so the
|
|
114
|
+
* copy carries the version whose schema it pairs with. */
|
|
109
115
|
export function renderUpdateUnit(options) {
|
|
110
116
|
const { execPath, npmPath, entry, pierHome, shellPath } = options;
|
|
111
117
|
const cli = join(dirname(entry), "cli.js");
|
|
@@ -120,12 +126,16 @@ Type=oneshot
|
|
|
120
126
|
# resolves node from PATH, and systemd's minimal PATH has no fnm/nvm node —
|
|
121
127
|
# the install then dies with "node: not found" with the tree half written.
|
|
122
128
|
${environment("PATH", pathEnv(execPath, shellPath))}
|
|
123
|
-
${pierHome ? `${environment("PIER_HOME", pierHome)}\n` : ""}ExecStart
|
|
124
|
-
ExecStart=${quote(execPath, true)} ${quote(cli, true)} backup
|
|
129
|
+
${pierHome ? `${environment("PIER_HOME", pierHome)}\n` : ""}ExecStart=${quote(execPath, true)} ${quote(cli, true)} backup
|
|
125
130
|
# npm runs under the recorded node: its shebang needs a node on PATH too.
|
|
126
131
|
ExecStart=${quote(execPath, true)} ${quote(npmPath, true)} install -g @timqi/pier@latest
|
|
127
|
-
#
|
|
128
|
-
#
|
|
132
|
+
# Last, because everything above it is downtime the running Pier does not owe
|
|
133
|
+
# anyone. A failed backup or install never gets here: the service stays up on
|
|
134
|
+
# the code it already loaded.
|
|
135
|
+
ExecStart=systemctl --user stop ${UNIT_NAME}
|
|
136
|
+
# ExecStopPost runs on success and failure. After a failed step above, Pier was
|
|
137
|
+
# never stopped and this is a no-op; it is here for the window that is still
|
|
138
|
+
# fatal — the unit dying between that stop and the start of the new version.
|
|
129
139
|
ExecStopPost=systemctl --user start ${UNIT_NAME}
|
|
130
140
|
`;
|
|
131
141
|
}
|
|
@@ -300,7 +310,7 @@ export function startUpdate(options) {
|
|
|
300
310
|
if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
|
|
301
311
|
return "failed";
|
|
302
312
|
say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
|
|
303
|
-
say(`Pier
|
|
313
|
+
say(`Pier snapshots the database into db/backups/ and installs while still up, then stops and starts on the new version.`);
|
|
304
314
|
return "started";
|
|
305
315
|
}
|
|
306
316
|
export function uninstall(home = homedir(), say = console.log, exec) {
|
|
@@ -247,6 +247,10 @@ export class TaskDefinitions {
|
|
|
247
247
|
return this.router.stateOf(sessionId) !== undefined ||
|
|
248
248
|
(await this.factory.find(sessionId)) !== undefined;
|
|
249
249
|
}
|
|
250
|
+
/** A caller's own directory — what a relative or omitted cwd resolves against. */
|
|
251
|
+
async sessionCwd(sessionId) {
|
|
252
|
+
return (await this.factory.find(sessionId))?.cwd;
|
|
253
|
+
}
|
|
250
254
|
/** The guard `ownerOf` exists for, on the three ways a definition changes. */
|
|
251
255
|
assertOwner(task, by, what) {
|
|
252
256
|
const owner = ownerOf(task);
|