pi-harness-runtime 1.1.389 → 1.1.391
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/AGENTS.md +12 -6
- package/README.md +242 -0
- package/harness/key-file-audit.ts +535 -0
- package/harness/startup-lines.ts +168 -0
- package/harness/startup-report.ts +277 -0
- package/index.ts +438 -73
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/contracts/dist/workspace-env-format.d.ts +56 -3
- package/packages/contracts/dist/workspace-env-format.js +119 -22
- package/packages/contracts/dist/workspace-env.d.ts +4 -4
- package/packages/contracts/dist/workspace-env.js +426 -23
- package/packages/contracts/package.json +1 -1
- package/packages/contracts/src/workspace-env-format.ts +147 -17
- package/packages/contracts/src/workspace-env.ts +443 -24
- package/packages/contracts/test/workspace-env.test.ts +95 -7
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-sink/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/dist/adapters/line-adapter.d.ts +39 -0
- package/packages/notification/dist/adapters/line-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/line-adapter.js +124 -8
- package/packages/notification/dist/adapters/line-adapter.js.map +1 -1
- package/packages/notification/dist/base-adapter.d.ts +20 -0
- package/packages/notification/dist/base-adapter.d.ts.map +1 -1
- package/packages/notification/dist/base-adapter.js +17 -0
- package/packages/notification/dist/base-adapter.js.map +1 -1
- package/packages/notification/dist/notification-center.d.ts +17 -0
- package/packages/notification/dist/notification-center.d.ts.map +1 -1
- package/packages/notification/dist/notification-center.js +23 -0
- package/packages/notification/dist/notification-center.js.map +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/notification/src/adapters/line-adapter.ts +148 -12
- package/packages/notification/src/base-adapter.ts +28 -0
- package/packages/notification/src/notification-center.ts +25 -0
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/telegram/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/dist/harness-footer.d.ts +23 -8
- package/packages/tui/dist/harness-footer.d.ts.map +1 -1
- package/packages/tui/dist/harness-footer.js +144 -2
- package/packages/tui/dist/harness-footer.js.map +1 -1
- package/packages/tui/dist/index.d.ts +1 -1
- package/packages/tui/dist/index.d.ts.map +1 -1
- package/packages/tui/dist/index.js +1 -1
- package/packages/tui/dist/index.js.map +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/tui/src/harness-footer.ts +149 -2
- package/packages/tui/src/index.ts +1 -0
- package/packages/tui/test/harness-footer.test.ts +134 -15
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/scripts/typecheck-packages.mjs +42 -2
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key file audit - tell the user HOW to fix a missing credential.
|
|
3
|
+
*
|
|
4
|
+
* The startup summary used to say "credentials missing" and left the user
|
|
5
|
+
* guessing which file to create. This module scans the key directory
|
|
6
|
+
* (~/.pi-harness-runtime/keys) and reports, per feature, every credential that
|
|
7
|
+
* is neither in the environment nor in a key file - together with the exact
|
|
8
|
+
* `echo` command that creates the file.
|
|
9
|
+
*
|
|
10
|
+
* A credential is satisfied when
|
|
11
|
+
* - one of its environment variables is set (and not empty), or
|
|
12
|
+
* - one of its candidate key files exists and is not empty.
|
|
13
|
+
*
|
|
14
|
+
* The audit never reads or returns a secret value - only file names and the
|
|
15
|
+
* state of a file (missing / empty).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
export interface KeyRequirement {
|
|
23
|
+
/** Startup label that owns the credential ("Telegram", "Relay", "LINE", "Jev", "Qdrant"). */
|
|
24
|
+
feature: string;
|
|
25
|
+
/** Human readable name of the credential. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** Key files that can hold the credential - any one of them satisfies it. */
|
|
28
|
+
candidates: string[];
|
|
29
|
+
/** Environment variable that can hold the credential instead. */
|
|
30
|
+
env?: string;
|
|
31
|
+
/** Additional environment variables that also satisfy the credential. */
|
|
32
|
+
altEnv?: string[];
|
|
33
|
+
/** The key file the fix command creates. */
|
|
34
|
+
fixFile: string;
|
|
35
|
+
/** Placeholder value used in the fix command. */
|
|
36
|
+
placeholder: string;
|
|
37
|
+
/** Extra fix commands for the alternatives of this credential. */
|
|
38
|
+
alternates?: Array<{ file: string; placeholder: string }>;
|
|
39
|
+
/** Short hint appended to the fix command. */
|
|
40
|
+
hint?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The minimum number of characters the reader accepts.
|
|
43
|
+
*
|
|
44
|
+
* The audit must not call a credential "present" when the running code
|
|
45
|
+
* would reject it: `packages/skills/src/loader.ts` ignores a key file
|
|
46
|
+
* with 5 characters or less, `packages/jev-judge/src/index.ts` one with
|
|
47
|
+
* 10 or less.
|
|
48
|
+
*/
|
|
49
|
+
minLength?: number;
|
|
50
|
+
/**
|
|
51
|
+
* A check the reader applies to the value.
|
|
52
|
+
*
|
|
53
|
+
* The audit must not call a credential present when the runtime would
|
|
54
|
+
* reject the value: the Telegram allowed-user list needs at least one
|
|
55
|
+
* parseable id (`harness/telegram-auth.ts` keeps the ids that parse to a
|
|
56
|
+
* number greater than zero).
|
|
57
|
+
*/
|
|
58
|
+
validate?: (content: string, file?: string) => boolean;
|
|
59
|
+
/**
|
|
60
|
+
* A stricter check for the ENVIRONMENT value.
|
|
61
|
+
*
|
|
62
|
+
* The environment and the key files do not always accept the same
|
|
63
|
+
* format: RELAY_WS_URL must be a ws:// or wss:// endpoint, while the
|
|
64
|
+
* telegram-webhook-url.txt file may hold the https URL the ws URL is
|
|
65
|
+
* derived from.
|
|
66
|
+
*/
|
|
67
|
+
validateEnv?: (content: string) => boolean;
|
|
68
|
+
/**
|
|
69
|
+
* An optional credential.
|
|
70
|
+
*
|
|
71
|
+
* An optional credential is not reported as missing: no startup line
|
|
72
|
+
* owns it, so a fix command for it would never reach the user. The
|
|
73
|
+
* audit still knows about it.
|
|
74
|
+
*/
|
|
75
|
+
optional?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The credential inventory.
|
|
80
|
+
*
|
|
81
|
+
* The file names and the environment variables match the places the harness
|
|
82
|
+
* actually reads them from:
|
|
83
|
+
*
|
|
84
|
+
* - `harness/key-loader.ts` (KEY_FILE_MAP) for the credentials the loader
|
|
85
|
+
* maps into the environment,
|
|
86
|
+
* - `harness/telegram-auth.ts` and `scripts/unified-webhook-server.ts` for
|
|
87
|
+
* the Telegram and LINE credentials,
|
|
88
|
+
* - `packages/relay/src/relay-endpoint.ts` (RELAY_URL_SOURCES /
|
|
89
|
+
* RELAY_TOKEN_SOURCES) for the relay endpoint and token.
|
|
90
|
+
*
|
|
91
|
+
* A credential counts as satisfied when the environment variable it is read
|
|
92
|
+
* from is set, or when one of its candidate key files exists and is not
|
|
93
|
+
* empty. Where the runtime reads a credential from the FILE only, no
|
|
94
|
+
* environment variable is listed - the audit must not claim a credential is
|
|
95
|
+
* configured when the extension would still not find it.
|
|
96
|
+
*/
|
|
97
|
+
export const KEY_REQUIREMENTS: KeyRequirement[] = [
|
|
98
|
+
{
|
|
99
|
+
feature: "Telegram",
|
|
100
|
+
label: "bot token",
|
|
101
|
+
candidates: ["telegram-bot-token.txt"],
|
|
102
|
+
fixFile: "telegram-bot-token.txt",
|
|
103
|
+
placeholder: "<TOKEN>",
|
|
104
|
+
hint: "DM @BotFather -> /newbot",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
feature: "Telegram",
|
|
108
|
+
label: "chat id",
|
|
109
|
+
candidates: ["telegram-chat-id.txt"],
|
|
110
|
+
fixFile: "telegram-chat-id.txt",
|
|
111
|
+
placeholder: "<CHAT_ID>",
|
|
112
|
+
hint: "DM @userinfobot to learn your chat id",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
feature: "Telegram",
|
|
116
|
+
label: "allowed user ids",
|
|
117
|
+
candidates: ["telegram-allowed-users.txt", "telegram-chat-id.txt"],
|
|
118
|
+
env: "TELEGRAM_ALLOWED_USERS",
|
|
119
|
+
fixFile: "telegram-allowed-users.txt",
|
|
120
|
+
placeholder: "<USER_IDS>",
|
|
121
|
+
hint: "comma separated list (a legacy chat id file also works)",
|
|
122
|
+
// The reader keeps the ids that parse to a number greater than zero -
|
|
123
|
+
// a list of garbage satisfies neither the audit nor the runtime.
|
|
124
|
+
validate: (content) =>
|
|
125
|
+
content
|
|
126
|
+
.split(",")
|
|
127
|
+
.map((part) => part.trim())
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.some((part) => {
|
|
130
|
+
const id = Number.parseInt(part, 10);
|
|
131
|
+
return !Number.isNaN(id) && id > 0;
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
feature: "Relay",
|
|
136
|
+
label: "auth token",
|
|
137
|
+
candidates: ["relay-auth-token.txt", "telegram-webhook-secret.txt"],
|
|
138
|
+
env: "RELAY_AUTH_TOKEN",
|
|
139
|
+
fixFile: "relay-auth-token.txt",
|
|
140
|
+
placeholder: "<TOKEN>",
|
|
141
|
+
alternates: [{ file: "telegram-webhook-secret.txt", placeholder: "<SECRET>" }],
|
|
142
|
+
hint: "shared by the relay server and the relay client",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
feature: "Relay",
|
|
146
|
+
label: "WebSocket URL",
|
|
147
|
+
candidates: ["relay-ws-url.txt", "telegram-webhook-url.txt"],
|
|
148
|
+
env: "RELAY_WS_URL",
|
|
149
|
+
fixFile: "relay-ws-url.txt",
|
|
150
|
+
placeholder: "<WS_URL>",
|
|
151
|
+
alternates: [{ file: "telegram-webhook-url.txt", placeholder: "<WEBHOOK_URL>" }],
|
|
152
|
+
hint: "the relay URL is derived from the webhook URL",
|
|
153
|
+
// The relay resolver only accepts a ws:// or wss:// endpoint - the
|
|
154
|
+
// webhook URL form (http/https) is only accepted in the
|
|
155
|
+
// telegram-webhook-url.txt file, where the ws URL is derived from.
|
|
156
|
+
validate: (content, file) =>
|
|
157
|
+
file === "telegram-webhook-url.txt"
|
|
158
|
+
? isDerivableWebhookUrl(content.trim())
|
|
159
|
+
: isRelayWsEndpoint(content.trim()),
|
|
160
|
+
// The environment variable always holds the ws endpoint itself.
|
|
161
|
+
validateEnv: (content) => isRelayWsEndpoint(content.trim()),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
feature: "Honcho",
|
|
165
|
+
label: "API key",
|
|
166
|
+
candidates: ["honcho-api-key.txt"],
|
|
167
|
+
fixFile: "honcho-api-key.txt",
|
|
168
|
+
placeholder: "<KEY>",
|
|
169
|
+
hint: "Get key: https://app.honcho.dev/api-keys",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
feature: "LINE",
|
|
173
|
+
label: "channel access token",
|
|
174
|
+
candidates: ["line-access-token.txt"],
|
|
175
|
+
fixFile: "line-access-token.txt",
|
|
176
|
+
placeholder: "<ACCESS_TOKEN>",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
feature: "LINE",
|
|
180
|
+
label: "user id",
|
|
181
|
+
candidates: ["line-user-id.txt"],
|
|
182
|
+
fixFile: "line-user-id.txt",
|
|
183
|
+
placeholder: "<USER_ID>",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
feature: "LINE Webhook",
|
|
187
|
+
label: "channel secret",
|
|
188
|
+
candidates: ["line-channel-secret.txt"],
|
|
189
|
+
fixFile: "line-channel-secret.txt",
|
|
190
|
+
placeholder: "<CHANNEL_SECRET>",
|
|
191
|
+
hint: "only needed on the machine that runs the webhook server",
|
|
192
|
+
// No startup line owns this credential - it is only needed on the
|
|
193
|
+
// host that runs the webhook server - so it must not be reported as
|
|
194
|
+
// missing to every user.
|
|
195
|
+
optional: true,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
feature: "Jev",
|
|
199
|
+
label: "API key",
|
|
200
|
+
candidates: ["typesafe-api-key.txt", "openrouter-api-key.txt", "jev-api-key.txt"],
|
|
201
|
+
env: "TYPESAFE_API_KEY",
|
|
202
|
+
altEnv: ["OPENROUTER_API_KEY"],
|
|
203
|
+
fixFile: "typesafe-api-key.txt",
|
|
204
|
+
placeholder: "<API_KEY>",
|
|
205
|
+
alternates: [
|
|
206
|
+
{ file: "openrouter-api-key.txt", placeholder: "<API_KEY>" },
|
|
207
|
+
{ file: "jev-api-key.txt", placeholder: "<API_KEY>" },
|
|
208
|
+
],
|
|
209
|
+
hint: "one TypeSafe key or one OpenRouter key - never both in one file",
|
|
210
|
+
minLength: 11,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
feature: "Qdrant",
|
|
214
|
+
label: "cluster endpoint",
|
|
215
|
+
candidates: ["qdrant-cluster-url.txt"],
|
|
216
|
+
env: "QDRANT_CLUSTER_ENDPOINT",
|
|
217
|
+
fixFile: "qdrant-cluster-url.txt",
|
|
218
|
+
placeholder: "<CLUSTER_URL>",
|
|
219
|
+
minLength: 6,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
feature: "Qdrant",
|
|
223
|
+
label: "API key",
|
|
224
|
+
candidates: ["qdrant-api-key.txt"],
|
|
225
|
+
env: "QDRANT_API_KEY",
|
|
226
|
+
fixFile: "qdrant-api-key.txt",
|
|
227
|
+
placeholder: "<API_KEY>",
|
|
228
|
+
minLength: 6,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
feature: "Qdrant",
|
|
232
|
+
label: "embedding key",
|
|
233
|
+
candidates: ["openai-api-key.txt"],
|
|
234
|
+
env: "OPENAI_API_KEY",
|
|
235
|
+
fixFile: "openai-api-key.txt",
|
|
236
|
+
placeholder: "<API_KEY>",
|
|
237
|
+
hint: "OpenAI key used to embed the skills",
|
|
238
|
+
// The embedder accepts a TypeSafe or an OpenRouter key as well.
|
|
239
|
+
altEnv: ["TYPESAFE_API_KEY", "OPENROUTER_API_KEY"],
|
|
240
|
+
minLength: 6,
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
feature: "Paperclip",
|
|
244
|
+
label: "API key",
|
|
245
|
+
candidates: ["paperclip-api-key.txt"],
|
|
246
|
+
env: "PAPERCLIP_API_KEY",
|
|
247
|
+
fixFile: "paperclip-api-key.txt",
|
|
248
|
+
placeholder: "<API_KEY>",
|
|
249
|
+
// The Paperclip integration has no startup line - it only writes a
|
|
250
|
+
// hint to the log file - so the audit must not report it as missing.
|
|
251
|
+
optional: true,
|
|
252
|
+
},
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
export interface MissingCredential {
|
|
256
|
+
requirement: KeyRequirement;
|
|
257
|
+
/** The key file the fix command creates. */
|
|
258
|
+
file: string;
|
|
259
|
+
/**
|
|
260
|
+
* Why the credential is missing:
|
|
261
|
+
* - "missing" = the file does not exist
|
|
262
|
+
* - "empty" = it exists but holds nothing
|
|
263
|
+
* - "too short" = it holds a value the reader ignores (a key file with
|
|
264
|
+
* five characters or less, a Jev key with ten or less)
|
|
265
|
+
* - "invalid" = it holds a value the reader rejects (a list of garbage
|
|
266
|
+
* where an id is expected)
|
|
267
|
+
*/
|
|
268
|
+
reason: "missing" | "empty" | "too short" | "invalid";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Is the value a relay WebSocket endpoint?
|
|
273
|
+
*
|
|
274
|
+
* The relay resolver accepts a plain ws:// or wss:// URL without
|
|
275
|
+
* credentials and without a query string - the same rules the resolver
|
|
276
|
+
* applies (packages/relay/src/relay-endpoint.ts).
|
|
277
|
+
*/
|
|
278
|
+
function isRelayWsEndpoint(value: string): boolean {
|
|
279
|
+
if (!/^wss?:\/\//i.test(value)) return false;
|
|
280
|
+
// The resolver rejects a URL that carries credentials or a query string.
|
|
281
|
+
if (value.includes("@") || value.includes("?")) return false;
|
|
282
|
+
try {
|
|
283
|
+
const url = new URL(value);
|
|
284
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:") return false;
|
|
285
|
+
return url.host.length > 0;
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Can a WebSocket URL be derived from this webhook URL?
|
|
293
|
+
*
|
|
294
|
+
* The resolver derives the ws URL from any URL that has a host (the
|
|
295
|
+
* scheme decides whether the result is ws:// or wss://).
|
|
296
|
+
*/
|
|
297
|
+
function isDerivableWebhookUrl(value: string): boolean {
|
|
298
|
+
try {
|
|
299
|
+
const url = new URL(value);
|
|
300
|
+
return url.host.length > 0;
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface KeyFileAuditOptions {
|
|
307
|
+
/** The key directory to scan (e.g. ~/.pi-harness-runtime/keys). */
|
|
308
|
+
keysDir: string;
|
|
309
|
+
/** Environment to inspect (defaults to process.env). */
|
|
310
|
+
env?: Record<string, string | undefined>;
|
|
311
|
+
/** File reader override (used by the tests). */
|
|
312
|
+
readFile?: (path: string) => string | undefined;
|
|
313
|
+
/** Existence check override (used by the tests). */
|
|
314
|
+
exists?: (path: string) => boolean;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface KeyFileAuditResult {
|
|
318
|
+
/** Every credential that could not be resolved. */
|
|
319
|
+
missing: MissingCredential[];
|
|
320
|
+
/** The unresolved credentials grouped by their owning feature. */
|
|
321
|
+
byFeature: Map<string, MissingCredential[]>;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Scan the key directory and report every credential that cannot be resolved.
|
|
326
|
+
*/
|
|
327
|
+
export function auditKeyFiles(options: KeyFileAuditOptions): KeyFileAuditResult {
|
|
328
|
+
const env = options.env ?? process.env;
|
|
329
|
+
const exists = options.exists ?? ((path: string) => existsSync(path));
|
|
330
|
+
const readFile =
|
|
331
|
+
options.readFile ??
|
|
332
|
+
((path: string) => (existsSync(path) ? readFileSync(path, "utf8") : undefined));
|
|
333
|
+
|
|
334
|
+
const missing: MissingCredential[] = [];
|
|
335
|
+
|
|
336
|
+
for (const requirement of KEY_REQUIREMENTS) {
|
|
337
|
+
if (requirement.optional) continue;
|
|
338
|
+
const envNames = [requirement.env, ...(requirement.altEnv ?? [])].filter(
|
|
339
|
+
(name): name is string => typeof name === "string" && name.length > 0,
|
|
340
|
+
);
|
|
341
|
+
const envSatisfied = envNames.some((name) => {
|
|
342
|
+
const value = (env[name] ?? "").trim();
|
|
343
|
+
if (!value) return false;
|
|
344
|
+
// The reader applies the same check to the environment value as
|
|
345
|
+
// to the file content: a list of garbage yields no usable id.
|
|
346
|
+
// The environment has its own rule when the requirement defines
|
|
347
|
+
// one (RELAY_WS_URL must be a ws endpoint, while the webhook URL
|
|
348
|
+
// file may hold an https URL).
|
|
349
|
+
const check = requirement.validateEnv ?? requirement.validate;
|
|
350
|
+
if (check && !check(value)) return false;
|
|
351
|
+
return true;
|
|
352
|
+
});
|
|
353
|
+
const minLength = requirement.minLength ?? 1;
|
|
354
|
+
const fileSatisfied = requirement.candidates.some((file) => {
|
|
355
|
+
const path = join(options.keysDir, file);
|
|
356
|
+
if (!exists(path)) return false;
|
|
357
|
+
const content = (readFile(path) ?? "").trim();
|
|
358
|
+
// The reader may reject a too short value (a key file with 5
|
|
359
|
+
// characters or less is ignored by the skills loader) - the audit
|
|
360
|
+
// must not call such a credential present.
|
|
361
|
+
if (content.length < minLength) return false;
|
|
362
|
+
// The reader may also reject the value itself (an allowed-user
|
|
363
|
+
// list of garbage yields no usable id).
|
|
364
|
+
if (requirement.validate && !requirement.validate(content, file)) return false;
|
|
365
|
+
return true;
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (envSatisfied || fileSatisfied) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const fixPath = join(options.keysDir, requirement.fixFile);
|
|
373
|
+
const fixContent = exists(fixPath) ? (readFile(fixPath) ?? "").trim() : "";
|
|
374
|
+
let reason: MissingCredential["reason"] = "missing";
|
|
375
|
+
if (exists(fixPath)) {
|
|
376
|
+
if (fixContent.length === 0) {
|
|
377
|
+
reason = "empty";
|
|
378
|
+
} else if (fixContent.length < minLength) {
|
|
379
|
+
// The reader ignores a value that is too short.
|
|
380
|
+
reason = "too short";
|
|
381
|
+
} else if (requirement.validate && !requirement.validate(fixContent, requirement.fixFile)) {
|
|
382
|
+
reason = "invalid";
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
missing.push({
|
|
386
|
+
requirement,
|
|
387
|
+
file: requirement.fixFile,
|
|
388
|
+
reason,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const byFeature = new Map<string, MissingCredential[]>();
|
|
393
|
+
for (const entry of missing) {
|
|
394
|
+
const list = byFeature.get(entry.requirement.feature) ?? [];
|
|
395
|
+
list.push(entry);
|
|
396
|
+
byFeature.set(entry.requirement.feature, list);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { missing, byFeature };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The key directory as it is shown to the user (~ instead of the home path). */
|
|
403
|
+
export function displayKeysDir(keysDir: string): string {
|
|
404
|
+
const home = homedir();
|
|
405
|
+
return keysDir.startsWith(home) ? join("~", keysDir.slice(home.length)) : keysDir;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The exact command that creates the missing key file.
|
|
410
|
+
*
|
|
411
|
+
* The placeholder is deliberately generic - the real value never reaches the
|
|
412
|
+
* startup summary, the log or the screen.
|
|
413
|
+
*/
|
|
414
|
+
export function formatKeyFileFix(
|
|
415
|
+
entry: MissingCredential,
|
|
416
|
+
keysDir: string,
|
|
417
|
+
file = entry.file,
|
|
418
|
+
placeholder = entry.requirement.placeholder,
|
|
419
|
+
): string {
|
|
420
|
+
const command = `echo "${placeholder}" > ${join(displayKeysDir(keysDir), file)}`;
|
|
421
|
+
// The reason only belongs to the primary file: an alternate file that
|
|
422
|
+
// does not exist yet is simply missing, not empty.
|
|
423
|
+
if (file === entry.file && entry.reason !== "missing") {
|
|
424
|
+
// The command replaces the content of the file - the user has to
|
|
425
|
+
// know that the value that is in there now is overwritten.
|
|
426
|
+
const note =
|
|
427
|
+
entry.reason === "empty"
|
|
428
|
+
? "# the file exists but is empty - the command replaces it"
|
|
429
|
+
: "# the file exists but the value is not accepted - the command replaces it";
|
|
430
|
+
return `${command} ${note}`;
|
|
431
|
+
}
|
|
432
|
+
return command;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The missing credentials of one feature, optionally narrowed to some labels.
|
|
437
|
+
*
|
|
438
|
+
* `labels` narrows the result to specific credentials (e.g. only the bot token
|
|
439
|
+
* and the chat id for a Telegram that is not configured yet).
|
|
440
|
+
*/
|
|
441
|
+
export function selectKeyFileFixes(
|
|
442
|
+
audit: KeyFileAuditResult | null,
|
|
443
|
+
feature: string,
|
|
444
|
+
labels?: string[],
|
|
445
|
+
): MissingCredential[] {
|
|
446
|
+
const entries = audit?.byFeature.get(feature);
|
|
447
|
+
if (!entries) return [];
|
|
448
|
+
if (!labels) return entries;
|
|
449
|
+
return entries.filter((entry) => labels.includes(entry.requirement.label));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The fix commands for a set of missing credentials, ready to be shown as
|
|
454
|
+
* startup details.
|
|
455
|
+
*
|
|
456
|
+
* Returns an empty array when nothing is missing.
|
|
457
|
+
*/
|
|
458
|
+
export function formatKeyFileFixes(
|
|
459
|
+
entries: MissingCredential[] | undefined,
|
|
460
|
+
keysDir: string,
|
|
461
|
+
): string[] {
|
|
462
|
+
if (!entries || entries.length === 0) return [];
|
|
463
|
+
const fixes: string[] = [];
|
|
464
|
+
const seenFiles = new Set<string>();
|
|
465
|
+
const seenHints = new Set<string>();
|
|
466
|
+
for (const entry of entries) {
|
|
467
|
+
const commands: Array<{ file: string; command: string }> = [];
|
|
468
|
+
// The hint explains WHERE the credential comes from - it is a prose
|
|
469
|
+
// line, not a command.
|
|
470
|
+
const hint = entry.requirement.hint;
|
|
471
|
+
if (hint && !seenHints.has(hint)) {
|
|
472
|
+
seenHints.add(hint);
|
|
473
|
+
fixes.push(hint);
|
|
474
|
+
}
|
|
475
|
+
commands.push({ file: entry.file, command: formatKeyFileFix(entry, keysDir) });
|
|
476
|
+
for (const alternate of entry.requirement.alternates ?? []) {
|
|
477
|
+
commands.push({
|
|
478
|
+
file: alternate.file,
|
|
479
|
+
command: formatKeyFileFix(entry, keysDir, alternate.file, alternate.placeholder),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
for (const { file, command } of commands) {
|
|
483
|
+
// One fix per key file: a credential whose fix file is also the
|
|
484
|
+
// fix file of another credential must not be listed twice.
|
|
485
|
+
if (seenFiles.has(file)) continue;
|
|
486
|
+
seenFiles.add(file);
|
|
487
|
+
fixes.push(command);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return fixes;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The fix commands of a feature, computed from the requirement list alone.
|
|
495
|
+
*
|
|
496
|
+
* This is the fallback for the case where the audit cannot answer - the
|
|
497
|
+
* credential is then known to be missing (the runtime found it missing) but
|
|
498
|
+
* the audit has nothing to say about it (a key file that is present but too
|
|
499
|
+
* short for the reader, or a failed audit).
|
|
500
|
+
*/
|
|
501
|
+
export function fallbackKeyFileFixes(
|
|
502
|
+
feature: string,
|
|
503
|
+
labels: string[],
|
|
504
|
+
keysDir: string,
|
|
505
|
+
options?: { exists?: (path: string) => boolean; readFile?: (path: string) => string | undefined },
|
|
506
|
+
): string[] {
|
|
507
|
+
const exists = options?.exists ?? ((path: string) => existsSync(path));
|
|
508
|
+
const readFile = options?.readFile ?? ((path: string) => (existsSync(path) ? readFileSync(path, "utf8") : undefined));
|
|
509
|
+
const fixes: string[] = [];
|
|
510
|
+
const seenFiles = new Set<string>();
|
|
511
|
+
for (const requirement of KEY_REQUIREMENTS) {
|
|
512
|
+
if (requirement.feature !== feature) continue;
|
|
513
|
+
if (labels.length > 0 && !labels.includes(requirement.label)) continue;
|
|
514
|
+
if (seenFiles.has(requirement.fixFile)) continue;
|
|
515
|
+
seenFiles.add(requirement.fixFile);
|
|
516
|
+
const path = join(keysDir, requirement.fixFile);
|
|
517
|
+
const present = exists(path) && (readFile(path) ?? "").trim().length > 0;
|
|
518
|
+
if (present) {
|
|
519
|
+
// The credential is in place but the runtime could not use it.
|
|
520
|
+
// Overwriting it with a placeholder would destroy a working
|
|
521
|
+
// secret - point at the file instead.
|
|
522
|
+
fixes.push(
|
|
523
|
+
`The file exists - check its value: ${join(displayKeysDir(keysDir), requirement.fixFile)}`,
|
|
524
|
+
);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
fixes.push(
|
|
528
|
+
formatKeyFileFix(
|
|
529
|
+
{ requirement, file: requirement.fixFile, reason: "missing" },
|
|
530
|
+
keysDir,
|
|
531
|
+
),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return fixes;
|
|
535
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering of the startup summary lines.
|
|
3
|
+
*
|
|
4
|
+
* The startup widget renders one line per service and the fix instructions
|
|
5
|
+
* below it. A fix instruction is usually a shell command that creates a key
|
|
6
|
+
* file - and such a command is longer than a narrow terminal can show.
|
|
7
|
+
*
|
|
8
|
+
* A truncated command is worse than no command: the user copies a broken
|
|
9
|
+
* path. A command that does not fit is therefore split into several lines -
|
|
10
|
+
* the directory to change to, the command itself and, when even that does
|
|
11
|
+
* not fit, the redirect on a line of its own (with a backslash continuation,
|
|
12
|
+
* so the shell still runs it as one command).
|
|
13
|
+
*
|
|
14
|
+
* A command is NEVER cut off. Prose (a hint, "Then reload Pi.") may be
|
|
15
|
+
* truncated - unless it carries a path or an instruction: a path is put on a
|
|
16
|
+
* line of its own and never cut, and a sentence that does not fit is split
|
|
17
|
+
* at its separator (" - ", ", ") so that the actionable part survives.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render the detail lines of one startup message.
|
|
24
|
+
*
|
|
25
|
+
* @param detail The detail text - a shell command or a prose line.
|
|
26
|
+
* @param indent The tree indent the detail belongs under (e.g. "│ ").
|
|
27
|
+
* @param width The available terminal width.
|
|
28
|
+
*/
|
|
29
|
+
export function renderDetailLines(detail: string, indent: string, width: number): string[] {
|
|
30
|
+
// Only the shell commands are prefixed with "Fix:" - a prose detail
|
|
31
|
+
// ("Then reload Pi.") must not read like a command.
|
|
32
|
+
const isCommand = detail.startsWith("echo ");
|
|
33
|
+
const prefix = isCommand ? "Fix: " : "";
|
|
34
|
+
const line = `${indent} ${prefix}${detail}`;
|
|
35
|
+
if (visibleWidth(line) <= width) {
|
|
36
|
+
return [truncateToWidth(line, width)];
|
|
37
|
+
}
|
|
38
|
+
if (!isCommand) {
|
|
39
|
+
return renderProse(detail, indent, width);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The note ("# the file exists but is empty") is not part of the command.
|
|
43
|
+
const noteIndex = detail.indexOf(" # ");
|
|
44
|
+
const note = noteIndex >= 0 ? detail.slice(noteIndex).trim() : "";
|
|
45
|
+
const command = noteIndex >= 0 ? detail.slice(0, noteIndex).trimEnd() : detail;
|
|
46
|
+
const match = /^echo "(.*)" > (\S+)$/.exec(command);
|
|
47
|
+
if (!match) {
|
|
48
|
+
// Not a command we can split - show it truncated.
|
|
49
|
+
return [truncateToWidth(line, width)];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const placeholder = match[1];
|
|
53
|
+
const path = match[2];
|
|
54
|
+
const slash = path.lastIndexOf("/");
|
|
55
|
+
const dir = slash >= 0 ? path.slice(0, slash) : ".";
|
|
56
|
+
|
|
57
|
+
// The variants, from the shortest to the longest. The first one that
|
|
58
|
+
// fits into the terminal wins; the last one is printed untruncated when
|
|
59
|
+
// even that does not fit - a command must never be cut in the middle.
|
|
60
|
+
// `mkdir -p` makes the split safe: without it a missing directory would
|
|
61
|
+
// let the redirect create the file in the current working directory.
|
|
62
|
+
// The redirect keeps the full path - no `cd` - so pasting the command
|
|
63
|
+
// does not change the working directory of the user's shell.
|
|
64
|
+
const variants: string[][] = [
|
|
65
|
+
[
|
|
66
|
+
`${indent} Fix: mkdir -p ${dir}`,
|
|
67
|
+
`${indent} echo "${placeholder}" > ${path}`,
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
`${indent} Fix: mkdir -p ${dir}`,
|
|
71
|
+
`${indent} echo "${placeholder}" \\`,
|
|
72
|
+
`${indent} > ${path}`,
|
|
73
|
+
],
|
|
74
|
+
];
|
|
75
|
+
const fits = (lines: string[]) => lines.every((entry) => visibleWidth(entry) <= width);
|
|
76
|
+
const chosen = variants.find(fits) ?? variants[variants.length - 1];
|
|
77
|
+
const lines = [...chosen];
|
|
78
|
+
// The note goes on its own line - appended to the command it would be
|
|
79
|
+
// cut off in a narrow terminal.
|
|
80
|
+
if (note) {
|
|
81
|
+
lines.push(...renderNote(note, indent, width));
|
|
82
|
+
}
|
|
83
|
+
return lines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render a prose detail.
|
|
88
|
+
*
|
|
89
|
+
* The actionable part of a prose line must survive a narrow terminal: a path
|
|
90
|
+
* (e.g. "Configure LINE in ~/.pi-harness-runtime/keys/, then reload Pi.")
|
|
91
|
+
* goes onto a line of its own and is never cut, and a sentence that does not
|
|
92
|
+
* fit is split at a separator so that the instruction ("then reload Pi.")
|
|
93
|
+
* is not lost.
|
|
94
|
+
*/
|
|
95
|
+
function renderProse(detail: string, indent: string, width: number): string[] {
|
|
96
|
+
// A path anywhere in the prose goes onto a line of its own - never cut.
|
|
97
|
+
const pathMatch = /(\S*\/\S*)/.exec(detail);
|
|
98
|
+
if (pathMatch && pathMatch.index > 0) {
|
|
99
|
+
const raw = pathMatch[1];
|
|
100
|
+
// The path must be copyable - trailing punctuation belongs to the
|
|
101
|
+
// sentence, not to the path.
|
|
102
|
+
const path = raw.replace(/[,.;:)]+$/, "");
|
|
103
|
+
const head = detail.slice(0, pathMatch.index).trim();
|
|
104
|
+
const tail = detail.slice(pathMatch.index + raw.length).trim();
|
|
105
|
+
const lines: string[] = [];
|
|
106
|
+
if (head) lines.push(...splitProse(head, indent, width));
|
|
107
|
+
lines.push(`${indent} ${path}`);
|
|
108
|
+
if (tail) lines.push(...splitProse(tail, indent, width));
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
return splitProse(detail, indent, width);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Split a prose text that does not fit into the terminal.
|
|
116
|
+
*
|
|
117
|
+
* The split happens at a separator (" - ", " or ", " (", ", ") so that both
|
|
118
|
+
* halves keep their meaning - the actionable half (a file name, an
|
|
119
|
+
* environment variable, "then reload Pi.") is never lost. A part that still
|
|
120
|
+
* does not fit is split again.
|
|
121
|
+
*/
|
|
122
|
+
function splitProse(text: string, indent: string, width: number): string[] {
|
|
123
|
+
const line = `${indent} ${text}`;
|
|
124
|
+
if (visibleWidth(line) <= width) return [truncateToWidth(line, width)];
|
|
125
|
+
for (const separator of [" - ", " or ", " (", ", "]) {
|
|
126
|
+
const index = text.lastIndexOf(separator);
|
|
127
|
+
if (index <= 0) continue;
|
|
128
|
+
const head = text.slice(0, index).trim().replace(/[(\s]+$/, "");
|
|
129
|
+
const tail = text.slice(index + separator.length).trim().replace(/^[),;:\s]+/, "");
|
|
130
|
+
if (!head || !tail) continue;
|
|
131
|
+
return [...splitProse(head, indent, width), ...splitProse(tail, indent, width)];
|
|
132
|
+
}
|
|
133
|
+
// Last resort: wrap at a word boundary so that no word is cut in half.
|
|
134
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
135
|
+
let wrapped = "";
|
|
136
|
+
let offset = 0;
|
|
137
|
+
for (const word of words) {
|
|
138
|
+
const candidate = wrapped ? `${wrapped} ${word}` : word;
|
|
139
|
+
if (wrapped && visibleWidth(`${indent} ${candidate}`) > width) break;
|
|
140
|
+
const start = text.indexOf(word, offset);
|
|
141
|
+
if (start < 0) break;
|
|
142
|
+
wrapped = candidate;
|
|
143
|
+
offset = start + word.length;
|
|
144
|
+
}
|
|
145
|
+
if (wrapped && wrapped !== text) {
|
|
146
|
+
const rest = text.slice(offset).trim();
|
|
147
|
+
return [...splitProse(wrapped, indent, width), ...splitProse(rest, indent, width)];
|
|
148
|
+
}
|
|
149
|
+
return [truncateToWidth(line, width)];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Render the note of a command ("# the file exists but is empty - the command
|
|
154
|
+
* replaces it"). The note warns that a working credential is overwritten -
|
|
155
|
+
* that warning must survive a narrow terminal.
|
|
156
|
+
*/
|
|
157
|
+
function renderNote(note: string, indent: string, width: number): string[] {
|
|
158
|
+
const line = `${indent} ${note}`;
|
|
159
|
+
if (visibleWidth(line) <= width) return [line];
|
|
160
|
+
const index = note.indexOf(" - ");
|
|
161
|
+
if (index > 0) {
|
|
162
|
+
return [
|
|
163
|
+
truncateToWidth(`${indent} ${note.slice(0, index)}`, width),
|
|
164
|
+
truncateToWidth(`${indent} ${note.slice(index + 3)}`, width),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
return [truncateToWidth(line, width)];
|
|
168
|
+
}
|