@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.
Files changed (79) hide show
  1. package/dist/agent/config-sync.js +124 -92
  2. package/dist/config-sync-fetch.js +66 -79
  3. package/dist/db.js +5 -3
  4. package/dist/main.js +11 -4
  5. package/dist/service.js +17 -7
  6. package/dist/tasks/definitions.js +4 -0
  7. package/dist/tasks/tool.js +70 -13
  8. package/dist/web/instance.js +3 -3
  9. package/dist/web/public/assets/activity-Bv8fc5Wj.js +5 -0
  10. package/dist/web/public/assets/activity-Bv8fc5Wj.js.br +0 -0
  11. package/dist/web/public/assets/activity-Bv8fc5Wj.js.gz +0 -0
  12. package/dist/web/public/assets/boards-BRKjgEYX.js +1 -0
  13. package/dist/web/public/assets/boards-BRKjgEYX.js.br +0 -0
  14. package/dist/web/public/assets/boards-BRKjgEYX.js.gz +0 -0
  15. package/dist/web/public/assets/{code-p-Tq1bMK.js → code-BgAXjUH3.js} +1 -1
  16. package/dist/web/public/assets/{explorer-50no_DrD.js → explorer-Cq_Ertso.js} +3 -3
  17. package/dist/web/public/assets/explorer-Cq_Ertso.js.br +0 -0
  18. package/dist/web/public/assets/explorer-Cq_Ertso.js.gz +0 -0
  19. package/dist/web/public/assets/{ghostty-web-Bvhh3ZFk.js → ghostty-web-CZHJ0YFi.js} +1 -1
  20. package/dist/web/public/assets/ghostty-web-CZHJ0YFi.js.br +0 -0
  21. package/dist/web/public/assets/ghostty-web-CZHJ0YFi.js.gz +0 -0
  22. package/dist/web/public/assets/index-BH7ir5I4.css +2 -0
  23. package/dist/web/public/assets/index-BH7ir5I4.css.br +0 -0
  24. package/dist/web/public/assets/index-BH7ir5I4.css.gz +0 -0
  25. package/dist/web/public/assets/index-BrBNzaid.js +79 -0
  26. package/dist/web/public/assets/index-BrBNzaid.js.br +0 -0
  27. package/dist/web/public/assets/index-BrBNzaid.js.gz +0 -0
  28. package/dist/web/public/assets/runs-CvdNDaw8.js +1 -0
  29. package/dist/web/public/assets/runs-CvdNDaw8.js.br +0 -0
  30. package/dist/web/public/assets/runs-CvdNDaw8.js.gz +0 -0
  31. package/dist/web/public/assets/settings-BaVwkUUD.js +6 -0
  32. package/dist/web/public/assets/settings-BaVwkUUD.js.br +0 -0
  33. package/dist/web/public/assets/settings-BaVwkUUD.js.gz +0 -0
  34. package/dist/web/public/assets/task-runs-B-eoldcU.js +3 -0
  35. package/dist/web/public/assets/task-runs-B-eoldcU.js.br +0 -0
  36. package/dist/web/public/assets/task-runs-B-eoldcU.js.gz +0 -0
  37. package/dist/web/public/assets/tasks-CT4LECPQ.js +4 -0
  38. package/dist/web/public/assets/tasks-CT4LECPQ.js.br +0 -0
  39. package/dist/web/public/assets/tasks-CT4LECPQ.js.gz +0 -0
  40. package/dist/web/public/assets/terminal-C0qFDFou.js +2 -0
  41. package/dist/web/public/assets/terminal-C0qFDFou.js.br +0 -0
  42. package/dist/web/public/assets/terminal-C0qFDFou.js.gz +0 -0
  43. package/dist/web/public/index.html +16 -16
  44. package/dist/web/public/index.html.br +0 -0
  45. package/dist/web/public/index.html.gz +0 -0
  46. package/docs/deploy.md +20 -8
  47. package/package.json +1 -1
  48. package/skills/pier-tasks/SKILL.md +20 -11
  49. package/dist/web/public/assets/activity-DYo7QS7k.js +0 -5
  50. package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
  51. package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
  52. package/dist/web/public/assets/boards-CwohTbtv.js +0 -1
  53. package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
  54. package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
  55. package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
  56. package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
  57. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
  58. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
  59. package/dist/web/public/assets/index-DuZ2vERd.css +0 -2
  60. package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
  61. package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
  62. package/dist/web/public/assets/index-WrsNstrb.js +0 -79
  63. package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
  64. package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
  65. package/dist/web/public/assets/runs-BAXZF-6r.js +0 -1
  66. package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
  67. package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
  68. package/dist/web/public/assets/settings-BmNXgRne.js +0 -6
  69. package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
  70. package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
  71. package/dist/web/public/assets/task-runs-CpgHxFLf.js +0 -3
  72. package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
  73. package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
  74. package/dist/web/public/assets/tasks-DybtogPF.js +0 -4
  75. package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
  76. package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
  77. package/dist/web/public/assets/terminal-m2_WYu5W.js +0 -2
  78. package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
  79. 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
- const MODEL_KEYS = ["id", "name", "reasoning", "input", "cost", "contextWindow", "maxTokens", "api"];
3
- const COST_KEYS = ["input", "output", "cacheRead", "cacheWrite"];
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
- if (!value || typeof value !== "object" || Array.isArray(value) ||
6
- ![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
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 value;
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
- function modelDefinition(value) {
17
- const model = record(value, "model");
18
- only(model, MODEL_KEYS, "model");
19
- if (typeof model.id !== "string" || !model.id.trim() || model.id !== model.id.trim()) {
20
- throw new Error("model id must be a non-empty trimmed string");
21
- }
22
- if (model.name !== undefined && (typeof model.name !== "string" || !model.name.length))
23
- throw new Error("model name must be a non-empty string");
24
- if (model.reasoning !== undefined && typeof model.reasoning !== "boolean") {
25
- throw new Error("model reasoning must be a boolean");
26
- }
27
- if (model.api !== undefined && (typeof model.api !== "string" || !/^[a-z][a-z0-9-]*$/.test(model.api))) {
28
- throw new Error("model api must be a protocol name");
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
- if (model.input !== undefined && (!Array.isArray(model.input) || !model.input.length ||
31
- model.input.some((input) => input !== "text" && input !== "image") ||
32
- new Set(model.input).size !== model.input.length)) {
33
- throw new Error("model input must contain text or image without duplicates");
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
- for (const key of ["contextWindow", "maxTokens"]) {
36
- if (model[key] !== undefined && (typeof model[key] !== "number" || !Number.isSafeInteger(model[key]) || model[key] <= 0)) {
37
- throw new Error(`model ${key} must be a positive integer`);
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
- if (model.cost !== undefined) {
41
- const cost = record(model.cost, "model cost");
42
- only(cost, COST_KEYS, "model cost");
43
- for (const key of COST_KEYS) {
44
- if (typeof cost[key] !== "number" || !Number.isFinite(cost[key]) || cost[key] < 0) {
45
- throw new Error(`model cost ${key} must be a non-negative finite number`);
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 structuredClone(model);
92
+ return shared;
50
93
  }
51
- /** Strict import boundary: unknown fields are rejected, never treated as masked secrets. */
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")).map(([id, value]) => {
63
- if (id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || ["constructor", "prototype"].includes(id)) {
64
- throw new Error("invalid snapshot provider id");
65
- }
66
- const provider = record(value, "snapshot provider");
67
- only(provider, ["models"], "snapshot provider");
68
- if (!Array.isArray(provider.models))
69
- throw new Error("snapshot provider models must be an array");
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
- /** Project disk metadata before validation, so no nested transport object can escape. */
78
- export function snapshotProviders(providers = {}) {
79
- return Object.fromEntries(Object.entries(providers).flatMap(([id, provider]) => {
80
- if (provider.models === undefined)
81
- return [];
82
- if (!Array.isArray(provider.models))
83
- throw new Error("models.json models must be an array");
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
- /** Replace only portable fields, retaining excluded fields for matching local model ids. */
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 providers = structuredClone(local);
99
- for (const [id, provider] of Object.entries(providers)) {
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 providers;
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 without turning an operator URL into an
2
- // arbitrary network proxy. DNS is checked once and the TLS request is pinned
3
- // to that address; redirects never inherit the token or bypass the check.
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 blockedV4 = new BlockList();
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
- const url = configSourceUrl(raw);
44
- const hostname = url.hostname.replace(/^\[|\]$/g, "");
45
- const addresses = isIP(hostname)
46
- ? [{ address: hostname, family: isIP(hostname) }]
47
- : await Promise.race([
48
- lookup(hostname, { all: true }),
49
- new Promise((_, reject) => {
50
- if (signal.aborted)
51
- reject(new Error("Configuration download aborted"));
52
- else
53
- signal.addEventListener("abort", () => reject(new Error("Configuration download aborted")), { once: true });
54
- }),
55
- ]);
56
- if (!addresses.length || addresses.some(({ address }) => !publicConfigAddress(address))) {
57
- throw new Error("Source must resolve to a public address");
58
- }
59
- signal.throwIfAborted();
60
- const address = addresses[0];
61
- return new Promise((resolve, reject) => {
62
- const req = request({
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
- if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers["content-type"] ?? "")) {
78
- res.destroy();
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
- const chunks = [];
83
- let size = 0;
84
- res.on("data", (chunk) => {
85
- size += chunk.length;
86
- if (size > CONFIG_SYNC_BYTES)
87
- res.destroy(new Error("Configuration exceeds 1 MiB"));
88
- else
89
- chunks.push(chunk);
90
- });
91
- res.on("error", () => reject(new Error(size > CONFIG_SYNC_BYTES ? "Configuration exceeds 1 MiB" : "Configuration download failed")));
92
- res.on("end", () => resolve({ status, etag: res.headers.etag ?? null, body: Buffer.concat(chunks).toString("utf8") }));
93
- });
94
- req.on("error", () => reject(new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source")));
95
- req.end();
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 while the service is stopped even when
365
- * the release has no schema migration. The previous complete copies stay put if
366
- * writing this one fails.
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 stops
209
- // the service, snapshots the database, installs and starts it again. Without
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 a second or two later. */
251
- const HANDOVER_GRACE_MS = 60_000;
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 stops Pier, takes a consistent backup, updates the exact
108
- * npm installation recorded at install time, and always starts Pier again. */
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=systemctl --user stop ${UNIT_NAME}
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
- # ExecStopPost runs on success and failure, so a failed backup or npm install
128
- # does not leave the previously working service stopped.
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 stops, snapshots the database into db/backups/, installs, then starts again.`);
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);