my-durable 0.0.12 → 0.0.14
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/apps/dpi-backend/src/main.ts +5 -0
- package/apps/dpi-backend/src/pi-config.ts +206 -0
- package/apps/dpi-backend/src/store.ts +98 -34
- package/bin/update.d.mts +2 -0
- package/bin/update.mjs +17 -4
- package/package.json +1 -1
|
@@ -170,6 +170,11 @@ server.listen(config.port, config.host, () => {
|
|
|
170
170
|
if (a.family === "IPv4" && !a.internal)
|
|
171
171
|
console.log(`LAN: http://${a.address}:${config.port}`);
|
|
172
172
|
console.log(`background: dpi device start | stop | status | install (service)`);
|
|
173
|
+
{
|
|
174
|
+
const providers = [...new Set(store.modelList.map((m) => m.provider))];
|
|
175
|
+
if (providers.length) console.log(`providers: ${providers.join(", ")} (pi auth/models.json + env)`);
|
|
176
|
+
for (const s of store.pi.skipped) console.log(` custom provider ${s.id} skipped: ${s.reason}`);
|
|
177
|
+
}
|
|
173
178
|
if (store.keyless)
|
|
174
179
|
console.log(
|
|
175
180
|
`no provider key: the model list is the built-in catalog; set OPENROUTER_API_KEY in ${config.envFile} to chat`,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device is pi: it reuses the SAME credentials and custom providers the pi
|
|
3
|
+
* CLI already has on this machine — the fix for "pi đầy đủ key, sao durable
|
|
4
|
+
* lại không". Nothing is copied or re-entered:
|
|
5
|
+
*
|
|
6
|
+
* ~/.pi/agent/auth.json provider credentials pi saved via /login
|
|
7
|
+
* (api_key and oauth entries, keyed by provider id)
|
|
8
|
+
* ~/.pi/agent/models.json custom providers ({baseUrl, api, apiKey?, models})
|
|
9
|
+
*
|
|
10
|
+
* PI_AGENT_DIR moves both (same variable pi honors). The device never WRITES
|
|
11
|
+
* pi's files: its credential store is a read-only view, so a headless daemon
|
|
12
|
+
* cannot corrupt an interactive login.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
18
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
19
|
+
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
20
|
+
import {
|
|
21
|
+
createProvider,
|
|
22
|
+
envApiKeyAuth,
|
|
23
|
+
type ApiKeyCredential,
|
|
24
|
+
type Credential,
|
|
25
|
+
type CredentialInfo,
|
|
26
|
+
type CredentialStore,
|
|
27
|
+
type Provider,
|
|
28
|
+
} from "@earendil-works/pi-ai";
|
|
29
|
+
|
|
30
|
+
/** pi's agent directory on this machine (PI_AGENT_DIR overrides, like pi). */
|
|
31
|
+
export function piAgentDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
32
|
+
return env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** auth.json: one type-tagged credential per provider id. */
|
|
36
|
+
export function readPiAuth(dir: string): Record<string, Credential> {
|
|
37
|
+
return readJson(join(dir, "auth.json"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** models.json custom providers: {providers: {name: {baseUrl, api, apiKey?, models[]}}}. */
|
|
41
|
+
export function readPiModelsJson(dir: string): PiModelsJson {
|
|
42
|
+
return readJson(join(dir, "models.json"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readJson(path: string): Record<string, never> | never {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
48
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
49
|
+
} catch {
|
|
50
|
+
return {}; // absent or unreadable: the device simply has no pi credentials
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PiModelsJson {
|
|
55
|
+
providers?: Record<
|
|
56
|
+
string,
|
|
57
|
+
{
|
|
58
|
+
baseUrl?: string;
|
|
59
|
+
api?: string;
|
|
60
|
+
apiKey?: string;
|
|
61
|
+
name?: string;
|
|
62
|
+
models?: PiCustomModel[];
|
|
63
|
+
}
|
|
64
|
+
>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PiCustomModel {
|
|
68
|
+
id: string;
|
|
69
|
+
name?: string;
|
|
70
|
+
contextWindow?: number;
|
|
71
|
+
reasoning?: boolean;
|
|
72
|
+
cost?: { input?: number; output?: number };
|
|
73
|
+
[key: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read-only credential view over pi's files, honoring pi's precedence
|
|
78
|
+
* (auth.json credential, then the models.json apiKey, then provider env vars —
|
|
79
|
+
* env resolution stays inside pi-ai). Writes are refused by returning the
|
|
80
|
+
* current value: the daemon must not touch an interactive login.
|
|
81
|
+
*/
|
|
82
|
+
export function piCredentialStore(auth: Record<string, Credential>, modelsJson: PiModelsJson): CredentialStore {
|
|
83
|
+
const of = (providerId: string): Credential | undefined => {
|
|
84
|
+
const fromAuth = auth[providerId];
|
|
85
|
+
if (fromAuth) return fromAuth;
|
|
86
|
+
const key = modelsJson.providers?.[providerId]?.apiKey;
|
|
87
|
+
if (key) {
|
|
88
|
+
const credential: ApiKeyCredential = { type: "api_key", key };
|
|
89
|
+
return credential;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
async read(providerId) {
|
|
95
|
+
return of(providerId);
|
|
96
|
+
},
|
|
97
|
+
async list(): Promise<readonly CredentialInfo[]> {
|
|
98
|
+
const ids = new Set([...Object.keys(auth), ...Object.keys(modelsJson.providers ?? {})]);
|
|
99
|
+
return [...ids]
|
|
100
|
+
.map(of)
|
|
101
|
+
.filter((c): c is Credential => !!c)
|
|
102
|
+
.map((c, i) => ({ providerId: [...new Set([...Object.keys(auth), ...Object.keys(modelsJson.providers ?? {})])][i]!, type: c.type }));
|
|
103
|
+
},
|
|
104
|
+
async modify(providerId, fn) {
|
|
105
|
+
const current = of(providerId);
|
|
106
|
+
const next = await fn(current);
|
|
107
|
+
return next === current ? current : current; // read-only: never persist
|
|
108
|
+
},
|
|
109
|
+
async delete() {
|
|
110
|
+
/* read-only */
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Provider ids the device has SOME credential for (auth.json or models.json). */
|
|
116
|
+
export function credentialedProviders(auth: Record<string, Credential>, modelsJson: PiModelsJson): string[] {
|
|
117
|
+
return [...new Set([...Object.keys(auth), ...Object.keys(modelsJson.providers ?? {})])].filter((id) => {
|
|
118
|
+
if (auth[id]) return true;
|
|
119
|
+
return !!modelsJson.providers?.[id]?.apiKey;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** pi models.json api id → pi-ai API implementation. Unknown ids are skipped. */
|
|
124
|
+
const API_IMPLS: Record<string, () => ReturnType<typeof openAICompletionsApi>> = {
|
|
125
|
+
"openai-completions": () => openAICompletionsApi(),
|
|
126
|
+
"openai-responses": () => openAIResponsesApi() as never,
|
|
127
|
+
"anthropic-messages": () => anthropicMessagesApi() as never,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build pi-ai providers for the models.json custom entries. An entry with an
|
|
132
|
+
* api we cannot serve (or no models) is skipped and REPORTED, not silently
|
|
133
|
+
* dropped — the operator should learn why a model is missing.
|
|
134
|
+
*/
|
|
135
|
+
export function piCustomProviders(
|
|
136
|
+
modelsJson: PiModelsJson,
|
|
137
|
+
onSkip?: (id: string, reason: string) => void,
|
|
138
|
+
): Provider[] {
|
|
139
|
+
const out: Provider[] = [];
|
|
140
|
+
for (const [id, entry] of Object.entries(modelsJson.providers ?? {})) {
|
|
141
|
+
const reason = !entry.baseUrl
|
|
142
|
+
? "no baseUrl"
|
|
143
|
+
: !entry.api || !API_IMPLS[entry.api]
|
|
144
|
+
? `unsupported api ${String(entry.api)}`
|
|
145
|
+
: !entry.models?.length
|
|
146
|
+
? "no models"
|
|
147
|
+
: "";
|
|
148
|
+
if (reason) {
|
|
149
|
+
onSkip?.(id, reason);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
out.push(
|
|
153
|
+
createProvider({
|
|
154
|
+
id,
|
|
155
|
+
name: entry.name ?? id,
|
|
156
|
+
baseUrl: entry.baseUrl,
|
|
157
|
+
auth: { apiKey: envApiKeyAuth(`${id} API key`, [`${id.toUpperCase().replace(/-/g, "_")}_API_KEY`]) },
|
|
158
|
+
models: entry.models!.map((m) => ({
|
|
159
|
+
type: "chat",
|
|
160
|
+
id: m.id,
|
|
161
|
+
name: m.name ?? m.id,
|
|
162
|
+
provider: id,
|
|
163
|
+
api: entry.api!,
|
|
164
|
+
baseUrl: entry.baseUrl,
|
|
165
|
+
contextWindow: m.contextWindow ?? 128000,
|
|
166
|
+
reasoning: m.reasoning ?? false,
|
|
167
|
+
input: ["text"],
|
|
168
|
+
cost: { input: m.cost?.input ?? 0, output: m.cost?.output ?? 0 },
|
|
169
|
+
...(m as Record<string, unknown>),
|
|
170
|
+
})) as never,
|
|
171
|
+
api: API_IMPLS[entry.api!]!(),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Everything pi brings on this machine: credentials + custom providers. */
|
|
179
|
+
export interface PiConfig {
|
|
180
|
+
dir: string;
|
|
181
|
+
auth: Record<string, Credential>;
|
|
182
|
+
modelsJson: PiModelsJson;
|
|
183
|
+
credentials: CredentialStore;
|
|
184
|
+
customProviders: Provider[];
|
|
185
|
+
/** Provider ids with a usable credential (auth.json or models.json key). */
|
|
186
|
+
credentialed: string[];
|
|
187
|
+
skipped: Array<{ id: string; reason: string }>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Load pi's configuration; absent files mean simply "nothing from pi". */
|
|
191
|
+
export function loadPiConfig(env: NodeJS.ProcessEnv = process.env): PiConfig {
|
|
192
|
+
const dir = piAgentDir(env);
|
|
193
|
+
const auth = existsSync(join(dir, "auth.json")) ? readPiAuth(dir) : {};
|
|
194
|
+
const modelsJson = existsSync(join(dir, "models.json")) ? readPiModelsJson(dir) : {};
|
|
195
|
+
const skipped: Array<{ id: string; reason: string }> = [];
|
|
196
|
+
const customProviders = piCustomProviders(modelsJson, (id, reason) => skipped.push({ id, reason }));
|
|
197
|
+
return {
|
|
198
|
+
dir,
|
|
199
|
+
auth,
|
|
200
|
+
modelsJson,
|
|
201
|
+
credentials: piCredentialStore(auth, modelsJson),
|
|
202
|
+
customProviders,
|
|
203
|
+
credentialed: credentialedProviders(auth, modelsJson),
|
|
204
|
+
skipped,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -41,6 +41,8 @@ import { createRequire } from "node:module";
|
|
|
41
41
|
import { BACKGROUND_CONTEXT as ctx, withAbortSignal } from "@earendil-works/chord/context";
|
|
42
42
|
import { createModels } from "@earendil-works/pi-ai/models";
|
|
43
43
|
import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter";
|
|
44
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
45
|
+
import { loadPiConfig } from "./pi-config.js";
|
|
44
46
|
import { curatedModels, defaultModelId } from "./model-catalog.js";
|
|
45
47
|
import { fauxModels, installFaux } from "./faux.js";
|
|
46
48
|
import {
|
|
@@ -161,7 +163,9 @@ const decodeCursor = (cursor?: string) => {
|
|
|
161
163
|
};
|
|
162
164
|
|
|
163
165
|
export class Store {
|
|
164
|
-
|
|
166
|
+
/** pi's credentials + custom providers on THIS machine (auth.json / models.json). */
|
|
167
|
+
readonly pi = loadPiConfig();
|
|
168
|
+
readonly models = createModels({ credentials: this.pi.credentials });
|
|
165
169
|
readonly opened = new Map<string, Opened>();
|
|
166
170
|
readonly errors = new Map<string, string>();
|
|
167
171
|
readonly catalog: DatabaseSync;
|
|
@@ -199,6 +203,12 @@ export class Store {
|
|
|
199
203
|
private constructor(readonly options: StoreOptions) {
|
|
200
204
|
if (options.provider === "faux") installFaux(this.models);
|
|
201
205
|
else {
|
|
206
|
+
// The device is pi: register EVERY built-in provider (the same registry
|
|
207
|
+
// the pi CLI uses), then keep OpenRouter narrowed to the curated list.
|
|
208
|
+
// Credentials arrive through pi's own files via the credential store;
|
|
209
|
+
// env keys still work through each provider.
|
|
210
|
+
for (const provider of builtinProviders()) this.models.setProvider(provider);
|
|
211
|
+
for (const provider of this.pi.customProviders) this.models.setProvider(provider);
|
|
202
212
|
const provider = openrouterProvider();
|
|
203
213
|
const catalog = provider.getModels();
|
|
204
214
|
for (const model of curatedModels)
|
|
@@ -311,30 +321,82 @@ export class Store {
|
|
|
311
321
|
reasoning: false,
|
|
312
322
|
cost: { input: 0, output: 0 },
|
|
313
323
|
}));
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
.
|
|
330
|
-
|
|
331
|
-
|
|
324
|
+
// The device serves every provider pi can authenticate on this machine:
|
|
325
|
+
// OpenRouter (the curated default) plus each provider pi holds a credential
|
|
326
|
+
// for (auth.json / models.json). Asking per-provider keeps providers with
|
|
327
|
+
// no credential out of the auth check entirely.
|
|
328
|
+
const ids = new Set(["openrouter", ...this.pi.credentialed]);
|
|
329
|
+
const out: ModelInfo[] = [];
|
|
330
|
+
let authenticated = false;
|
|
331
|
+
for (const id of ids) {
|
|
332
|
+
const available = await this.models
|
|
333
|
+
.getAvailable(id, { signal: AbortSignal.timeout(10_000) })
|
|
334
|
+
.catch(() => [] as ReadonlyArray<{ id: string }>);
|
|
335
|
+
if (!available.length) continue;
|
|
336
|
+
authenticated = true;
|
|
337
|
+
if (id === "openrouter") {
|
|
338
|
+
// The curated list, with live context/pricing when the key allows it.
|
|
339
|
+
out.push(
|
|
340
|
+
...curatedModels.flatMap((c) => {
|
|
341
|
+
const m = available.find((x) => x.id === c.id) as
|
|
342
|
+
| { id: string; contextWindow?: number; reasoning?: boolean; cost?: { input: number; output: number } }
|
|
343
|
+
| undefined;
|
|
344
|
+
return m
|
|
345
|
+
? [{
|
|
346
|
+
id: c.id,
|
|
347
|
+
provider: "openrouter",
|
|
348
|
+
name: c.name,
|
|
349
|
+
tier: c.tier,
|
|
350
|
+
icon: c.icon,
|
|
351
|
+
contextWindow: m.contextWindow ?? 128000,
|
|
352
|
+
reasoning: m.reasoning ?? false,
|
|
353
|
+
cost: { input: m.cost?.input ?? 0, output: m.cost?.output ?? 0 },
|
|
354
|
+
}]
|
|
355
|
+
: [];
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
} else {
|
|
359
|
+
for (const m of available as ReadonlyArray<{
|
|
360
|
+
id: string;
|
|
361
|
+
name?: string;
|
|
362
|
+
contextWindow?: number;
|
|
363
|
+
reasoning?: boolean;
|
|
364
|
+
cost?: { input: number; output: number };
|
|
365
|
+
}>)
|
|
366
|
+
out.push({
|
|
367
|
+
id: m.id,
|
|
368
|
+
provider: id,
|
|
369
|
+
name: m.name ?? m.id,
|
|
370
|
+
tier: "·",
|
|
371
|
+
icon: "lab.png",
|
|
372
|
+
contextWindow: m.contextWindow ?? 128000,
|
|
373
|
+
reasoning: m.reasoning ?? false,
|
|
374
|
+
cost: { input: m.cost?.input ?? 0, output: m.cost?.output ?? 0 },
|
|
375
|
+
});
|
|
376
|
+
}
|
|
332
377
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
378
|
+
if (!authenticated) {
|
|
379
|
+
this.modelsKeyless = true;
|
|
380
|
+
// No credential anywhere: the built-in curated catalog (static metadata)
|
|
381
|
+
// so picking a model is never blocked; chat needs a key.
|
|
382
|
+
const hasChatFields = (m: unknown): m is { id: string; contextWindow: number; reasoning: boolean; cost: { input: number; output: number } } =>
|
|
383
|
+
typeof m === "object" && m !== null && "contextWindow" in m && "reasoning" in m;
|
|
384
|
+
out.push(
|
|
385
|
+
...this.models
|
|
386
|
+
.getAllModels("openrouter")
|
|
387
|
+
.filter((m) => hasChatFields(m) && curatedModels.some((c) => c.id === m.id))
|
|
388
|
+
.map(
|
|
389
|
+
(m) =>
|
|
390
|
+
hasChatFields(m)
|
|
391
|
+
? { id: m.id, contextWindow: m.contextWindow, reasoning: m.reasoning, cost: { input: m.cost.input, output: m.cost.output } }
|
|
392
|
+
: undefined,
|
|
393
|
+
)
|
|
394
|
+
.filter(
|
|
395
|
+
(m): m is { id: string; contextWindow: number; reasoning: boolean; cost: { input: number; output: number } } => !!m,
|
|
396
|
+
)
|
|
397
|
+
.map((m) => {
|
|
398
|
+
const c = curatedModels.find((x) => x.id === m.id)!;
|
|
399
|
+
return {
|
|
338
400
|
id: m.id,
|
|
339
401
|
provider: "openrouter",
|
|
340
402
|
name: c.name,
|
|
@@ -343,10 +405,11 @@ export class Store {
|
|
|
343
405
|
contextWindow: m.contextWindow,
|
|
344
406
|
reasoning: m.reasoning,
|
|
345
407
|
cost: { input: m.cost.input, output: m.cost.output },
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
408
|
+
} satisfies ModelInfo;
|
|
409
|
+
}),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
350
413
|
}
|
|
351
414
|
|
|
352
415
|
routedModel(model?: ModelRef): ModelRef {
|
|
@@ -354,6 +417,10 @@ export class Store {
|
|
|
354
417
|
const id = fauxModels.some((m) => m.id === model?.modelId) ? model!.modelId : fauxModels[0].id;
|
|
355
418
|
return { provider: "faux", modelId: id };
|
|
356
419
|
}
|
|
420
|
+
// A model the device actually serves (pi provider or curated openrouter)
|
|
421
|
+
// keeps its provider; everything else routes to the OpenRouter default.
|
|
422
|
+
if (model && this.modelList.some((m) => m.provider === model.provider && m.id === model.modelId))
|
|
423
|
+
return { provider: model.provider, modelId: model.modelId };
|
|
357
424
|
const exact = model?.provider === "openrouter" ? model.modelId : `${model?.provider}/${model?.modelId}`;
|
|
358
425
|
return {
|
|
359
426
|
provider: "openrouter",
|
|
@@ -365,12 +432,9 @@ export class Store {
|
|
|
365
432
|
const m = model as ModelRef | undefined;
|
|
366
433
|
if (!m || typeof m.provider !== "string" || typeof m.modelId !== "string")
|
|
367
434
|
throw invalid("model must be { provider, modelId }");
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
: m.provider === "openrouter" &&
|
|
372
|
-
curatedModels.some((c) => c.id === m.modelId) &&
|
|
373
|
-
!!this.models.getModel(m.provider, m.modelId);
|
|
435
|
+
// Known = listed in this device's catalog (faux models, curated OpenRouter,
|
|
436
|
+
// or any provider pi authenticated here). The list IS the contract.
|
|
437
|
+
const known = this.modelList.some((x) => x.provider === m.provider && x.id === m.modelId);
|
|
374
438
|
if (!known) throw invalid(`Unknown model ${m.provider}/${m.modelId}. Choose one from GET /v1/device models`);
|
|
375
439
|
return { provider: m.provider, modelId: m.modelId };
|
|
376
440
|
}
|
package/bin/update.d.mts
CHANGED
|
@@ -22,3 +22,5 @@ export function daemonControl(
|
|
|
22
22
|
dpiPath: string,
|
|
23
23
|
port: number,
|
|
24
24
|
): { running: () => Promise<boolean>; stop: () => Promise<number>; start: () => Promise<number> };
|
|
25
|
+
/** Below this the app cannot run at all (node:sqlite). The only hard block. */
|
|
26
|
+
export const REQUIRED_NODE_FLOOR: string;
|
package/bin/update.mjs
CHANGED
|
@@ -31,6 +31,12 @@ export function compareVersions(a, b) {
|
|
|
31
31
|
return 0;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The floor we KNOW breaks: below 22.5.0 there is no node:sqlite, and the
|
|
36
|
+
* device dies at boot. This — and only this — is a hard block.
|
|
37
|
+
*/
|
|
38
|
+
export const REQUIRED_NODE_FLOOR = "22.5.0";
|
|
39
|
+
|
|
34
40
|
/**
|
|
35
41
|
* Does `current` satisfy `required` when required looks like ">=22.19.0" /
|
|
36
42
|
* ">=22" / "22.x"? Only the forms our own package.json can carry are supported;
|
|
@@ -94,15 +100,22 @@ export async function runUpdate(deps) {
|
|
|
94
100
|
return 0;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
//
|
|
103
|
+
// pi's philosophy, which we follow: engines metadata is ADVICE. pi declares
|
|
104
|
+
// node >=22.19.0 too, npm only warns, and nothing enforces it at runtime —
|
|
105
|
+
// so an update proceeds with a visible warning. We hard-block ONLY below the
|
|
106
|
+
// floor we know is broken (no node:sqlite ⇒ the device cannot even boot).
|
|
98
107
|
const required = pkg.engines?.node;
|
|
99
|
-
if (
|
|
108
|
+
if (compareVersions(deps.nodeVersion, REQUIRED_NODE_FLOOR) < 0) {
|
|
100
109
|
printErr(
|
|
101
|
-
`dpi update:
|
|
110
|
+
`dpi update: Node ${deps.nodeVersion} is too old — durable needs Node >= ${REQUIRED_NODE_FLOOR} (node:sqlite).`,
|
|
102
111
|
);
|
|
103
|
-
printErr(" Upgrade Node
|
|
112
|
+
printErr(" Upgrade Node (nvm install --lts / winget upgrade OpenJS.NodeJS.LTS) and retry.");
|
|
104
113
|
return 1;
|
|
105
114
|
}
|
|
115
|
+
if (required && !satisfiesEngine(deps.nodeVersion, required)) {
|
|
116
|
+
print(`note: this release declares Node ${required}, you have ${deps.nodeVersion}.`);
|
|
117
|
+
print(` like pi, it installs and runs anyway; upgrade Node if anything misbehaves.`);
|
|
118
|
+
}
|
|
106
119
|
|
|
107
120
|
// Windows: the daemon (and a TUI) hold files inside the package folder, so npm
|
|
108
121
|
// cannot rename it. Stop the daemon, install, then bring it back.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-durable",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "Durable Pi \u2014 the durable agent network: hub web UI, device backend daemon, TUI admin and the shared SDK, packaged as one runnable monorepo install",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|