@rosetears/aili-pi 0.1.0 → 0.1.3
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/README.md +15 -6
- package/THIRD_PARTY_NOTICES.md +14 -3
- package/extensions/header/index.ts +92 -0
- package/extensions/matrix/index.ts +375 -0
- package/extensions/zentui/config.ts +1014 -0
- package/extensions/zentui/extension-status.ts +96 -0
- package/extensions/zentui/fixed-editor/cluster.ts +98 -0
- package/extensions/zentui/fixed-editor/compositor.ts +719 -0
- package/extensions/zentui/fixed-editor/index.ts +223 -0
- package/extensions/zentui/fixed-editor/input.ts +85 -0
- package/extensions/zentui/fixed-editor/pi-compat.ts +296 -0
- package/extensions/zentui/fixed-editor/selection.ts +217 -0
- package/extensions/zentui/fixed-editor/terminal-modes.ts +75 -0
- package/extensions/zentui/fixed-editor/types.ts +37 -0
- package/extensions/zentui/footer-format.ts +279 -0
- package/extensions/zentui/footer.ts +595 -0
- package/extensions/zentui/format.ts +434 -0
- package/extensions/zentui/git.ts +384 -0
- package/extensions/zentui/gradient.ts +70 -0
- package/extensions/zentui/icons.ts +252 -0
- package/extensions/zentui/index.ts +580 -0
- package/extensions/zentui/live-context.ts +75 -0
- package/extensions/zentui/package-version.ts +650 -0
- package/extensions/zentui/project-refresh.ts +104 -0
- package/extensions/zentui/project-state.ts +59 -0
- package/extensions/zentui/prototype-patch-registry.ts +111 -0
- package/extensions/zentui/runtime.ts +841 -0
- package/extensions/zentui/selector-border.ts +77 -0
- package/extensions/zentui/session-lifecycle.ts +60 -0
- package/extensions/zentui/settings-command.ts +897 -0
- package/extensions/zentui/state.ts +55 -0
- package/extensions/zentui/style.ts +332 -0
- package/extensions/zentui/thinking-message.ts +159 -0
- package/extensions/zentui/tool-execution.ts +189 -0
- package/extensions/zentui/ui.ts +618 -0
- package/extensions/zentui/user-message.ts +252 -0
- package/licenses/pi-sakura-cyberdeck-MIT.txt +21 -0
- package/licenses/pi-zentui-MIT.txt +21 -0
- package/manifests/capabilities.json +1 -1
- package/manifests/live-verification.json +14 -8
- package/manifests/provenance.json +18 -5
- package/manifests/sbom.json +19 -3
- package/notices/pi-sakura-cyberdeck-NOTICE.txt +6 -0
- package/package.json +12 -3
- package/scripts/local-package-e2e.ts +1 -1
- package/scripts/sync-global-skills.d.mts +13 -0
- package/scripts/sync-global-skills.mjs +134 -0
- package/src/runtime/credential-guard.ts +58 -0
- package/src/runtime/doctor.ts +1 -1
- package/src/runtime/index.ts +2 -2
- package/src/runtime/path-boundaries.ts +1 -1
- package/src/runtime/registry.ts +6 -2
- package/src/runtime/rem-head.txt +38 -0
- package/src/runtime/rose-context.ts +1 -1
- package/src/runtime/subagents.ts +74 -403
- package/templates/APPEND_SYSTEM.md +10 -8
- package/themes/rem-cyberdeck.json +32 -0
- package/upstream/opencode-global-agents.lock.json +34 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project package version detection (Starship `package` module parity).
|
|
3
|
+
*
|
|
4
|
+
* Pure file parsing only — no shell-outs, no parent-directory traversal,
|
|
5
|
+
* no render-time reads. The reader operates on the current cwd and the
|
|
6
|
+
* same top-level entries the runtime detector sees, then hands back a
|
|
7
|
+
* silent null when no manifest is present or any parser fails.
|
|
8
|
+
*
|
|
9
|
+
* Supported manifest sources (intersected with runtimes Zentui detects):
|
|
10
|
+
* bun / nodejs → package.json
|
|
11
|
+
* deno → deno.json / deno.jsonc
|
|
12
|
+
* maven → pom.xml
|
|
13
|
+
* gradle → gradle.properties
|
|
14
|
+
* python → pyproject.toml, setup.cfg
|
|
15
|
+
* rust → Cargo.toml (package.version or workspace-inherited)
|
|
16
|
+
* php → composer.json
|
|
17
|
+
* crystal → shard.yml
|
|
18
|
+
* dart → pubspec.yaml, pubspec.yml
|
|
19
|
+
* elixir → mix.exs
|
|
20
|
+
* elm → elm.json
|
|
21
|
+
* fortran → fpm.toml
|
|
22
|
+
* gleam → gleam.toml
|
|
23
|
+
* haskell → *.cabal
|
|
24
|
+
* helm → Chart.yaml
|
|
25
|
+
* julia → Project.toml
|
|
26
|
+
* meson → meson.build
|
|
27
|
+
* nim → *.nimble
|
|
28
|
+
* ruby → *.gemspec
|
|
29
|
+
* vlang → v.mod
|
|
30
|
+
* xmake → xmake.lua
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
export type PackageVersionResult = {
|
|
37
|
+
/** Starship ecosystem key — matches `runtimeMetadata[].name` where it exists. */
|
|
38
|
+
ecosystem: string;
|
|
39
|
+
/** Raw version string as authored in the manifest. */
|
|
40
|
+
version: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type PackageVersionReadResult =
|
|
44
|
+
| { kind: "ok"; result: PackageVersionResult | null }
|
|
45
|
+
| { kind: "error" };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A single manifest source. `kind` selects the lookup mode:
|
|
49
|
+
* "file" — try each of `files` exactly (cheap, no scan).
|
|
50
|
+
* "extensions" — pick the first entry in `cwd` whose extension matches one in `files`.
|
|
51
|
+
* `parse` receives the raw file text and either returns a cleaned version
|
|
52
|
+
* string or `undefined`. Parsers must be total (never throw); any
|
|
53
|
+
* failure means `undefined`.
|
|
54
|
+
*/
|
|
55
|
+
type ManifestSource = {
|
|
56
|
+
files: readonly string[];
|
|
57
|
+
parse: (raw: string) => string | undefined;
|
|
58
|
+
kind?: "file" | "extensions";
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const PACKAGE_VERSION_ECOSYSTEMS = [
|
|
62
|
+
"bun",
|
|
63
|
+
"nodejs",
|
|
64
|
+
"deno",
|
|
65
|
+
"maven",
|
|
66
|
+
"gradle",
|
|
67
|
+
"python",
|
|
68
|
+
"rust",
|
|
69
|
+
"php",
|
|
70
|
+
"crystal",
|
|
71
|
+
"dart",
|
|
72
|
+
"elixir",
|
|
73
|
+
"elm",
|
|
74
|
+
"fortran",
|
|
75
|
+
"gleam",
|
|
76
|
+
"haskell",
|
|
77
|
+
"helm",
|
|
78
|
+
"julia",
|
|
79
|
+
"meson",
|
|
80
|
+
"nim",
|
|
81
|
+
"ruby",
|
|
82
|
+
"vlang",
|
|
83
|
+
"xmake",
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
86
|
+
// --- String cleaning -----------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Strip surrounding quotes / leading `v` and trim trailing metadata.
|
|
90
|
+
* Empty / whitespace-only values return `undefined`.
|
|
91
|
+
*/
|
|
92
|
+
function cleanVersion(value: string | undefined): string | undefined {
|
|
93
|
+
if (!value) return undefined;
|
|
94
|
+
let text = value.trim();
|
|
95
|
+
if (!text) return undefined;
|
|
96
|
+
|
|
97
|
+
if (text.length >= 2) {
|
|
98
|
+
const first = text[0];
|
|
99
|
+
const last = text[text.length - 1];
|
|
100
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
101
|
+
text = text.slice(1, -1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
while (text.startsWith("v") || text.startsWith("V")) {
|
|
106
|
+
// Only strip `v` when the next char is a digit — `v3.2.1` → `3.2.1`,
|
|
107
|
+
// but a bare word like `via` is left alone.
|
|
108
|
+
const next = text[1];
|
|
109
|
+
if (!next || next < "0" || next > "9") break;
|
|
110
|
+
text = text.slice(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
text = text.trim();
|
|
114
|
+
if (!text) return undefined;
|
|
115
|
+
|
|
116
|
+
// Reject obvious junk (whitespace, control chars, braces).
|
|
117
|
+
if (/[\s\r\n\t]/.test(text)) return undefined;
|
|
118
|
+
if (/[{}\\<>]/.test(text)) return undefined;
|
|
119
|
+
|
|
120
|
+
return text;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- JSON helpers --------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function safeJsonParse(raw: string): unknown {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(raw);
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Strip `//` and `/* * /` comments so JSON-with-comments can be parsed by
|
|
135
|
+
* `JSON.parse`. Quoted strings are preserved. Only used for `.jsonc`.
|
|
136
|
+
*/
|
|
137
|
+
function stripJsonComments(raw: string): string {
|
|
138
|
+
let result = "";
|
|
139
|
+
let inString = false;
|
|
140
|
+
let stringQuote = "";
|
|
141
|
+
let inBlockComment = false;
|
|
142
|
+
let inLineComment = false;
|
|
143
|
+
for (let i = 0; i < raw.length; i++) {
|
|
144
|
+
const ch = raw[i];
|
|
145
|
+
const next = raw[i + 1];
|
|
146
|
+
if (inLineComment) {
|
|
147
|
+
if (ch === "\n") {
|
|
148
|
+
inLineComment = false;
|
|
149
|
+
result += "\n";
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (inBlockComment) {
|
|
154
|
+
if (ch === "*" && next === "/") {
|
|
155
|
+
inBlockComment = false;
|
|
156
|
+
i += 1;
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (inString) {
|
|
161
|
+
result += ch;
|
|
162
|
+
if (ch === "\\" && next !== undefined) {
|
|
163
|
+
result += next;
|
|
164
|
+
i += 1;
|
|
165
|
+
} else if (ch === stringQuote) {
|
|
166
|
+
inString = false;
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (ch === "/" && next === "/") {
|
|
171
|
+
inLineComment = true;
|
|
172
|
+
i += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (ch === "/" && next === "*") {
|
|
176
|
+
inBlockComment = true;
|
|
177
|
+
i += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (ch === '"' || ch === "'") {
|
|
181
|
+
inString = true;
|
|
182
|
+
stringQuote = ch;
|
|
183
|
+
result += ch;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
result += ch;
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function safeJsoncParse(raw: string): unknown {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(stripJsonComments(raw));
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readJsonVersionField(raw: string, key: string): string | undefined {
|
|
200
|
+
const parsed = safeJsonParse(raw);
|
|
201
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
202
|
+
const value = (parsed as Record<string, unknown>)[key];
|
|
203
|
+
return typeof value === "string" ? value : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readJsoncVersionField(raw: string, key: string): string | undefined {
|
|
207
|
+
const parsed = safeJsoncParse(raw);
|
|
208
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
209
|
+
const value = (parsed as Record<string, unknown>)[key];
|
|
210
|
+
return typeof value === "string" ? value : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- TOML helpers --------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Parse only the static `key = "value"` (or `key = 'value'`) assignment of a
|
|
217
|
+
* simple TOML section header (e.g. `[project]`, `[tool.poetry]`, `[workspace]`).
|
|
218
|
+
* Sufficient for the manifest keys we care about; inline tables and
|
|
219
|
+
* multi-line arrays fall back to `undefined`.
|
|
220
|
+
*
|
|
221
|
+
* Sections are matched as a leading `[name]` (or `[[name]]`) header; key
|
|
222
|
+
* matches continue until the next section header or end of file.
|
|
223
|
+
*/
|
|
224
|
+
function readTomlSectionKeyValue(raw: string, section: string, key: string): string | undefined {
|
|
225
|
+
const lines = raw.split(/\r?\n/);
|
|
226
|
+
let inTargetSection = false;
|
|
227
|
+
|
|
228
|
+
for (const line of lines) {
|
|
229
|
+
const trimmed = line.trim();
|
|
230
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
231
|
+
|
|
232
|
+
const headerMatch = trimmed.match(/^\[\[?([^\]]+)\]?\]\s*$/);
|
|
233
|
+
if (headerMatch) {
|
|
234
|
+
const name = (headerMatch[1] ?? "").trim();
|
|
235
|
+
inTargetSection = name === section;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!inTargetSection) continue;
|
|
240
|
+
|
|
241
|
+
const match = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/);
|
|
242
|
+
if (!match) continue;
|
|
243
|
+
const candidateKey = match[1] ?? "";
|
|
244
|
+
if (candidateKey !== key) continue;
|
|
245
|
+
|
|
246
|
+
let value = (match[2] ?? "").trim();
|
|
247
|
+
const trailingComment = value.indexOf("#");
|
|
248
|
+
if (trailingComment >= 0 && !isInsideString(value, trailingComment)) {
|
|
249
|
+
value = value.slice(0, trailingComment).trim();
|
|
250
|
+
}
|
|
251
|
+
return value || undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A key-value pair at the top of a TOML file, before any section header. */
|
|
258
|
+
function readTopLevelTomlValue(raw: string, key: string): string | undefined {
|
|
259
|
+
const lines = raw.split(/\r?\n/);
|
|
260
|
+
for (const line of lines) {
|
|
261
|
+
const trimmed = line.trim();
|
|
262
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
263
|
+
if (trimmed.startsWith("[")) return undefined; // entered a section
|
|
264
|
+
const match = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*$/);
|
|
265
|
+
if (!match) continue;
|
|
266
|
+
const candidateKey = match[1] ?? "";
|
|
267
|
+
if (candidateKey !== key) continue;
|
|
268
|
+
return trimQuotes((match[2] ?? "").trim());
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function trimQuotes(value: string): string {
|
|
274
|
+
if (value.length < 2) return value;
|
|
275
|
+
const first = value[0];
|
|
276
|
+
const last = value[value.length - 1];
|
|
277
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
278
|
+
return value.slice(1, -1);
|
|
279
|
+
}
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isInsideString(value: string, index: number): boolean {
|
|
284
|
+
let inSingle = false;
|
|
285
|
+
let inDouble = false;
|
|
286
|
+
for (let i = 0; i < index; i++) {
|
|
287
|
+
const ch = value[i];
|
|
288
|
+
if (ch === "\\") {
|
|
289
|
+
i += 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!inDouble && ch === "'") inSingle = !inSingle;
|
|
293
|
+
else if (!inSingle && ch === '"') inDouble = !inDouble;
|
|
294
|
+
}
|
|
295
|
+
return inSingle || inDouble;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- YAML helper (line-oriented; supports only top-level `key:`) ---------
|
|
299
|
+
|
|
300
|
+
function readYamlTopLevelValue(raw: string, key: string): string | undefined {
|
|
301
|
+
const lines = raw.split(/\r?\n/);
|
|
302
|
+
const target = `${key}:`;
|
|
303
|
+
for (const line of lines) {
|
|
304
|
+
if (line.startsWith(" ") || line.startsWith("\t")) continue;
|
|
305
|
+
if (!line.startsWith(target)) continue;
|
|
306
|
+
const value = line.slice(target.length).trim();
|
|
307
|
+
return value || undefined;
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- Per-ecosystem parsers -----------------------------------------------
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Locate a `package` stanza in a `.cabal` file and extract its `version`
|
|
316
|
+
* field. Modern cabal files (>= 1.12) drop the explicit `package`
|
|
317
|
+
* keyword; the file itself IS the package stanza, with `library`,
|
|
318
|
+
* `executable`, `test-suite`, `common`, etc. stanzas delimited by
|
|
319
|
+
* column-0 openers. We accept both indented (` version: …`) and
|
|
320
|
+
* top-level (`version: …`) `version:` lines up to the first stanza opener.
|
|
321
|
+
*/
|
|
322
|
+
function extractCabalPackageVersion(raw: string): string | undefined {
|
|
323
|
+
const lines = raw.split(/\r?\n/);
|
|
324
|
+
let packageStart = 0;
|
|
325
|
+
for (let i = 0; i < lines.length; i++) {
|
|
326
|
+
const line = lines[i] ?? "";
|
|
327
|
+
if (/^package\s*$/i.test(line.trim())) {
|
|
328
|
+
packageStart = i + 1;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (let i = packageStart; i < lines.length; i++) {
|
|
334
|
+
const line = lines[i] ?? "";
|
|
335
|
+
if (line.trim() === "") continue;
|
|
336
|
+
if (line.trim().startsWith("--")) continue;
|
|
337
|
+
// A non-indented, non-empty line closes the package stanza.
|
|
338
|
+
if (!/^\s/.test(line)) break;
|
|
339
|
+
const match = line.match(/^\s*version\s*:\s*(.+?)\s*$/i);
|
|
340
|
+
if (match) return cleanVersion(match[1]);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Legacy form: `version:` lives at column 0 alongside `name:`, before
|
|
344
|
+
// any stanza opener (e.g. `library`).
|
|
345
|
+
for (let i = packageStart; i < lines.length; i++) {
|
|
346
|
+
const line = lines[i] ?? "";
|
|
347
|
+
const trimmed = line.trim();
|
|
348
|
+
if (trimmed === "" || trimmed.startsWith("--")) continue;
|
|
349
|
+
// First non-keyword, non-empty line ends the package section.
|
|
350
|
+
if (!/^[a-zA-Z][\w-]*\s*:/.test(trimmed)) break;
|
|
351
|
+
const match = trimmed.match(/^version\s*:\s*(.+?)\s*$/i);
|
|
352
|
+
if (match) return cleanVersion(match[1]);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const parsers: readonly ManifestSource[] = [
|
|
359
|
+
// npm / bun — package.json
|
|
360
|
+
{
|
|
361
|
+
files: ["package.json"],
|
|
362
|
+
parse: (raw) => cleanVersion(readJsonVersionField(raw, "version")),
|
|
363
|
+
},
|
|
364
|
+
// deno — deno.json / deno.jsonc
|
|
365
|
+
{
|
|
366
|
+
files: ["deno.json", "deno.jsonc"],
|
|
367
|
+
parse: (raw) => cleanVersion(readJsoncVersionField(raw, "version")),
|
|
368
|
+
},
|
|
369
|
+
// maven — pom.xml: project/version (direct child, simple case).
|
|
370
|
+
{
|
|
371
|
+
files: ["pom.xml"],
|
|
372
|
+
parse: (raw) => {
|
|
373
|
+
const match = raw.match(/<project\b[^>]*>[\s\S]*?<version>\s*([^<]+?)\s*<\/version>/i);
|
|
374
|
+
return cleanVersion(match?.[1]);
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
// gradle — gradle.properties key=value
|
|
378
|
+
{
|
|
379
|
+
files: ["gradle.properties"],
|
|
380
|
+
parse: (raw) => {
|
|
381
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
382
|
+
const match = line.match(/^\s*version\s*[:=]\s*(.+?)\s*$/i);
|
|
383
|
+
if (match) return cleanVersion(match[1]);
|
|
384
|
+
}
|
|
385
|
+
return undefined;
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
// python — pyproject.toml ([project].version or [tool.poetry].version)
|
|
389
|
+
{
|
|
390
|
+
files: ["pyproject.toml"],
|
|
391
|
+
parse: (raw) => {
|
|
392
|
+
const project = cleanVersion(readTomlSectionKeyValue(raw, "project", "version"));
|
|
393
|
+
if (project) return project;
|
|
394
|
+
return cleanVersion(readTomlSectionKeyValue(raw, "tool.poetry", "version"));
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
// python — setup.cfg ([metadata].version)
|
|
398
|
+
{
|
|
399
|
+
files: ["setup.cfg"],
|
|
400
|
+
parse: (raw) => cleanVersion(readTomlSectionKeyValue(raw, "metadata", "version")),
|
|
401
|
+
},
|
|
402
|
+
// rust — Cargo.toml ([package].version, with workspace inheritance)
|
|
403
|
+
{
|
|
404
|
+
files: ["Cargo.toml"],
|
|
405
|
+
parse: (raw) => {
|
|
406
|
+
// Direct assignment wins (string literal version).
|
|
407
|
+
const direct = readTomlSectionKeyValue(raw, "package", "version");
|
|
408
|
+
const directClean = cleanVersion(direct);
|
|
409
|
+
if (directClean && !/^\s*version\s*\.\s*workspace\s*=\s*true/i.test(direct ?? "")) {
|
|
410
|
+
return directClean;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Workspace inheritance: package declares `version.workspace = true`
|
|
414
|
+
// and the [workspace] block carries the actual version.
|
|
415
|
+
if (!/\bversion\s*\.\s*workspace\s*=\s*true/i.test(raw)) return undefined;
|
|
416
|
+
const workspaceMatch = raw.match(/\[workspace\][\s\S]*?version\s*=\s*["']([^"']+)["']/i);
|
|
417
|
+
return cleanVersion(workspaceMatch?.[1]);
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
// php — composer.json
|
|
421
|
+
{
|
|
422
|
+
files: ["composer.json"],
|
|
423
|
+
parse: (raw) => cleanVersion(readJsonVersionField(raw, "version")),
|
|
424
|
+
},
|
|
425
|
+
// crystal — shard.yml (top-level `version: …`)
|
|
426
|
+
{
|
|
427
|
+
files: ["shard.yml"],
|
|
428
|
+
parse: (raw) => cleanVersion(readYamlTopLevelValue(raw, "version")),
|
|
429
|
+
},
|
|
430
|
+
// dart — pubspec.yaml / pubspec.yml (top-level `version: …`)
|
|
431
|
+
{
|
|
432
|
+
files: ["pubspec.yaml", "pubspec.yml"],
|
|
433
|
+
parse: (raw) => cleanVersion(readYamlTopLevelValue(raw, "version")),
|
|
434
|
+
},
|
|
435
|
+
// elixir — mix.exs: `version: "…"` as a Mix.Project option (string literal form).
|
|
436
|
+
{
|
|
437
|
+
files: ["mix.exs"],
|
|
438
|
+
parse: (raw) => {
|
|
439
|
+
const match = raw.match(/(?:^|\n)\s*version\s*:\s*["']([^"']+)["']/);
|
|
440
|
+
return cleanVersion(match?.[1]);
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
// elm — elm.json: top-level `version` (only present in 0.19.0 application projects).
|
|
444
|
+
{
|
|
445
|
+
files: ["elm.json"],
|
|
446
|
+
parse: (raw) => cleanVersion(readJsonVersionField(raw, "version")),
|
|
447
|
+
},
|
|
448
|
+
// fortran — fpm.toml: [package] or [project] version.
|
|
449
|
+
{
|
|
450
|
+
files: ["fpm.toml"],
|
|
451
|
+
parse: (raw) => {
|
|
452
|
+
const fromPackage = cleanVersion(readTomlSectionKeyValue(raw, "package", "version"));
|
|
453
|
+
if (fromPackage) return fromPackage;
|
|
454
|
+
return cleanVersion(readTomlSectionKeyValue(raw, "project", "version"));
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
// gleam — gleam.toml top-level version
|
|
458
|
+
{
|
|
459
|
+
files: ["gleam.toml"],
|
|
460
|
+
parse: (raw) => cleanVersion(readTopLevelTomlValue(raw, "version")),
|
|
461
|
+
},
|
|
462
|
+
// haskell — *.cabal: `version:` inside a `package` stanza.
|
|
463
|
+
{
|
|
464
|
+
files: [".cabal"],
|
|
465
|
+
kind: "extensions",
|
|
466
|
+
parse: (raw) => extractCabalPackageVersion(raw),
|
|
467
|
+
},
|
|
468
|
+
// helm — Chart.yaml: top-level `version:` (chart version, distinct from appVersion)
|
|
469
|
+
{
|
|
470
|
+
files: ["Chart.yaml"],
|
|
471
|
+
parse: (raw) => cleanVersion(readYamlTopLevelValue(raw, "version")),
|
|
472
|
+
},
|
|
473
|
+
// julia — Project.toml: top-level `version = "…"`
|
|
474
|
+
{
|
|
475
|
+
files: ["Project.toml"],
|
|
476
|
+
parse: (raw) => cleanVersion(readTopLevelTomlValue(raw, "version")),
|
|
477
|
+
},
|
|
478
|
+
// meson — meson.build: static `project(..., version: '…')` form only.
|
|
479
|
+
{
|
|
480
|
+
files: ["meson.build"],
|
|
481
|
+
parse: (raw) => {
|
|
482
|
+
const match = raw.match(/\bproject\s*\([^)]*?\bversion\s*:\s*['"]([^'"]+)['"]/);
|
|
483
|
+
return cleanVersion(match?.[1]);
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
// nim — *.nimble: top-level `version = "…"` (ignore indented blocks).
|
|
487
|
+
{
|
|
488
|
+
files: [".nimble"],
|
|
489
|
+
kind: "extensions",
|
|
490
|
+
parse: (raw) => cleanVersion(readTopLevelTomlValue(raw, "version")),
|
|
491
|
+
},
|
|
492
|
+
// ruby — *.gemspec: `spec.version = "…"` static literal.
|
|
493
|
+
{
|
|
494
|
+
files: [".gemspec"],
|
|
495
|
+
kind: "extensions",
|
|
496
|
+
parse: (raw) => {
|
|
497
|
+
const quoted = raw.match(/\b(?:spec\.|\.)?version\s*=\s*["']([^"']+)["']/);
|
|
498
|
+
if (quoted) return cleanVersion(quoted[1]);
|
|
499
|
+
return undefined;
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
// vlang — v.mod: `version: '…'` after the module block.
|
|
503
|
+
{
|
|
504
|
+
files: ["v.mod"],
|
|
505
|
+
parse: (raw) => {
|
|
506
|
+
const match = raw.match(/\bversion\s*:\s*['"]([^'']+)['"]/);
|
|
507
|
+
if (match) return cleanVersion(match[1]);
|
|
508
|
+
const numeric = raw.match(/\bversion\s*:\s*([0-9][^\s,]+)/);
|
|
509
|
+
return cleanVersion(numeric?.[1]);
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
// xmake — xmake.lua: static `set_version("…")` form only.
|
|
513
|
+
{
|
|
514
|
+
files: ["xmake.lua"],
|
|
515
|
+
parse: (raw) => {
|
|
516
|
+
const match = raw.match(/^\s*set_version\s*\(\s*["']([^"']+)["']\s*\)/m);
|
|
517
|
+
return cleanVersion(match?.[1]);
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
];
|
|
521
|
+
|
|
522
|
+
// --- File lookup ---------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
function ecosystemFor(file: string): string {
|
|
525
|
+
const lower = file.toLowerCase();
|
|
526
|
+
if (lower === "package.json") return "nodejs";
|
|
527
|
+
if (lower === "pom.xml") return "maven";
|
|
528
|
+
if (lower === "gradle.properties") return "gradle";
|
|
529
|
+
if (lower === "deno.json" || lower === "deno.jsonc") return "deno";
|
|
530
|
+
if (lower === "pyproject.toml") return "python";
|
|
531
|
+
if (lower === "setup.cfg") return "python";
|
|
532
|
+
if (lower === "cargo.toml") return "rust";
|
|
533
|
+
if (lower === "composer.json") return "php";
|
|
534
|
+
if (lower === "shard.yml") return "crystal";
|
|
535
|
+
if (lower === "pubspec.yaml" || lower === "pubspec.yml") return "dart";
|
|
536
|
+
if (lower === "mix.exs") return "elixir";
|
|
537
|
+
if (lower === "elm.json") return "elm";
|
|
538
|
+
if (lower === "fpm.toml") return "fortran";
|
|
539
|
+
if (lower === "gleam.toml") return "gleam";
|
|
540
|
+
if (lower.endsWith(".cabal")) return "haskell";
|
|
541
|
+
if (lower === "chart.yaml") return "helm";
|
|
542
|
+
if (lower === "project.toml") return "julia";
|
|
543
|
+
if (lower === "meson.build") return "meson";
|
|
544
|
+
if (lower.endsWith(".nimble")) return "nim";
|
|
545
|
+
if (lower.endsWith(".gemspec")) return "ruby";
|
|
546
|
+
if (lower === "v.mod") return "vlang";
|
|
547
|
+
if (lower === "xmake.lua") return "xmake";
|
|
548
|
+
return "unknown";
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function safeReadFile(path: string): string | undefined {
|
|
552
|
+
try {
|
|
553
|
+
return readFileSync(path, "utf8");
|
|
554
|
+
} catch {
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Test if a `cwd` entry basename ends with one of the given extensions.
|
|
561
|
+
* `extensions` should include the leading dot.
|
|
562
|
+
*/
|
|
563
|
+
function hasAnyExtension(name: string, extensions: readonly string[]): boolean {
|
|
564
|
+
const lower = name.toLowerCase();
|
|
565
|
+
for (const ext of extensions) {
|
|
566
|
+
if (lower.endsWith(ext)) return true;
|
|
567
|
+
}
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function readPackageVersion(cwd: string): PackageVersionResult | null {
|
|
572
|
+
// File-mode readers: cheap `existsSync` checks in deterministic order.
|
|
573
|
+
for (let i = 0; i < parsers.length; i++) {
|
|
574
|
+
const source = parsers[i];
|
|
575
|
+
if (!source || source.kind === "extensions") continue;
|
|
576
|
+
for (const file of source.files) {
|
|
577
|
+
const full = join(cwd, file);
|
|
578
|
+
if (!existsSync(full)) continue;
|
|
579
|
+
const raw = safeReadFile(full);
|
|
580
|
+
if (raw === undefined) continue;
|
|
581
|
+
let version: string | undefined;
|
|
582
|
+
try {
|
|
583
|
+
version = source.parse(raw);
|
|
584
|
+
} catch {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (version) return { ecosystem: ecosystemFor(file), version };
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Extension-mode readers: one readdir, then try each candidate against
|
|
592
|
+
// its parser. The reading order matches `parsers` declaration order so
|
|
593
|
+
// Haskell (`*.cabal`) is preferred over Ruby (`*.gemspec`) when both
|
|
594
|
+
// exist (very rare; deterministic and documented).
|
|
595
|
+
let entries: readonly string[];
|
|
596
|
+
try {
|
|
597
|
+
entries = readdirSync(cwd);
|
|
598
|
+
} catch {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
for (let i = 0; i < parsers.length; i++) {
|
|
603
|
+
const source = parsers[i];
|
|
604
|
+
if (source?.kind !== "extensions") continue;
|
|
605
|
+
for (const entry of entries) {
|
|
606
|
+
if (!hasAnyExtension(entry, source.files)) continue;
|
|
607
|
+
const full = join(cwd, entry);
|
|
608
|
+
const raw = safeReadFile(full);
|
|
609
|
+
if (raw === undefined) continue;
|
|
610
|
+
let version: string | undefined;
|
|
611
|
+
try {
|
|
612
|
+
version = source.parse(raw);
|
|
613
|
+
} catch {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (version) return { ecosystem: ecosystemFor(entry), version };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Async wrapper for use from the project-refresh path. `null` results from
|
|
625
|
+
* `readPackageVersion` (no manifest present) are surfaced as `ok` with a
|
|
626
|
+
* null result so the caller can distinguish "no manifest in this cwd"
|
|
627
|
+
* (clear state) from "could not read cwd" / parse failure (error, keep
|
|
628
|
+
* last-good). The synchronous reader never throws on its own; we still
|
|
629
|
+
* wrap defensively so callers using this through a scheduler cannot be
|
|
630
|
+
* broken by future filesystem exceptions.
|
|
631
|
+
*/
|
|
632
|
+
export async function readPackageVersionResult(cwd: string): Promise<PackageVersionReadResult> {
|
|
633
|
+
try {
|
|
634
|
+
return { kind: "ok", result: readPackageVersion(cwd) };
|
|
635
|
+
} catch {
|
|
636
|
+
return { kind: "error" };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Exposed for testing — the parser table and the `cleanVersion` helper.
|
|
642
|
+
* Lets unit tests cover every ecosystem and edge case without writing
|
|
643
|
+
* temporary filesystem fixtures.
|
|
644
|
+
*/
|
|
645
|
+
export const __test__ = {
|
|
646
|
+
parsers,
|
|
647
|
+
cleanVersion,
|
|
648
|
+
ecosystemFor,
|
|
649
|
+
hasAnyExtension,
|
|
650
|
+
};
|