@gjsify/cli 0.39.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.gjs.mjs +197 -180
- package/lib/cli-app.js +11 -25
- package/lib/commands/flatpak/check.js +1 -1
- package/lib/commands/flatpak/ci.js +1 -1
- package/lib/commands/flatpak/diff.js +1 -1
- package/lib/commands/flatpak/init.js +1 -1
- package/lib/commands/flatpak/scaffold.d.ts +2 -4
- package/lib/commands/flatpak/scaffold.js +25 -298
- package/lib/commands/flatpak/sync-flathub.js +1 -1
- package/lib/commands/foreach.js +34 -20
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/commands/ship.d.ts +11 -0
- package/lib/commands/ship.js +217 -0
- package/lib/config.d.ts +12 -0
- package/lib/config.js +15 -0
- package/lib/types/config-data.d.ts +136 -46
- package/lib/utils/app-metadata.d.ts +51 -0
- package/lib/utils/app-metadata.js +338 -0
- package/lib/utils/cli-runtime-closure.d.ts +23 -0
- package/lib/utils/cli-runtime-closure.js +274 -0
- package/lib/utils/publish-headers.d.ts +13 -0
- package/lib/utils/publish-headers.js +30 -4
- package/lib/utils/ship/ar.d.ts +21 -0
- package/lib/utils/ship/ar.js +57 -0
- package/lib/utils/ship/bytes.d.ts +4 -0
- package/lib/utils/ship/bytes.js +23 -0
- package/lib/utils/ship/cpio.d.ts +22 -0
- package/lib/utils/ship/cpio.js +93 -0
- package/lib/utils/ship/deb.d.ts +24 -0
- package/lib/utils/ship/deb.js +118 -0
- package/lib/utils/ship/depends.d.ts +62 -0
- package/lib/utils/ship/depends.js +202 -0
- package/lib/utils/ship/discover.d.ts +21 -0
- package/lib/utils/ship/discover.js +129 -0
- package/lib/utils/ship/formats.d.ts +5 -0
- package/lib/utils/ship/formats.js +83 -0
- package/lib/utils/ship/gi-namespaces.d.ts +7 -0
- package/lib/utils/ship/gi-namespaces.js +49 -0
- package/lib/utils/ship/gzip.d.ts +2 -0
- package/lib/utils/ship/gzip.js +21 -0
- package/lib/utils/ship/launcher.d.ts +7 -0
- package/lib/utils/ship/launcher.js +37 -0
- package/lib/utils/ship/payload.d.ts +30 -0
- package/lib/utils/ship/payload.js +59 -0
- package/lib/utils/ship/plan.d.ts +34 -0
- package/lib/utils/ship/plan.js +167 -0
- package/lib/utils/ship/rpm-header.d.ts +29 -0
- package/lib/utils/ship/rpm-header.js +170 -0
- package/lib/utils/ship/rpm.d.ts +12 -0
- package/lib/utils/ship/rpm.js +377 -0
- package/lib/utils/ship/scripts.d.ts +31 -0
- package/lib/utils/ship/scripts.js +76 -0
- package/lib/utils/ship/settings.d.ts +60 -0
- package/lib/utils/ship/settings.js +184 -0
- package/lib/utils/ship/stage-writer.d.ts +17 -0
- package/lib/utils/ship/stage-writer.js +49 -0
- package/lib/utils/ship/types.d.ts +114 -0
- package/lib/utils/ship/types.js +10 -0
- package/lib/utils/ship/version.d.ts +17 -0
- package/lib/utils/ship/version.js +55 -0
- package/package.json +19 -18
- /package/lib/templates/{flatpak → app}/desktop.tmpl +0 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// AppStream MetaInfo XML + freedesktop `.desktop` rendering, shared by every
|
|
2
|
+
// install format.
|
|
3
|
+
//
|
|
4
|
+
// These files describe the APPLICATION, not the container it ships in: a
|
|
5
|
+
// `.deb`, an `.rpm`, a Flatpak and (later) a macOS bundle all want the same
|
|
6
|
+
// `<name>`, `<summary>`, categories, keywords and icon name. They lived under
|
|
7
|
+
// `commands/flatpak/` while Flatpak was the only packer; ADR 0024 § 8 moves
|
|
8
|
+
// them out so `gjsify ship` reads them without importing another command.
|
|
9
|
+
//
|
|
10
|
+
// The MetaInfo XML is built line by line in TypeScript rather than substituted
|
|
11
|
+
// into a template: the AppStream surface (description blocks, per-release rich
|
|
12
|
+
// notes, translator hints, kudos, supports/requires/recommends, content_rating
|
|
13
|
+
// attributes, provides) has too many optional nested sections for a
|
|
14
|
+
// template+placeholder approach to stay legible. The `.desktop` entry stays
|
|
15
|
+
// template-based — it is flat `key=value`.
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
/**
|
|
18
|
+
* Lazy template loader. `static-read-inliner` matches this exact shape
|
|
19
|
+
* (`readFileSync(new URL(<literal>, import.meta.url), 'utf-8')`) and inlines
|
|
20
|
+
* the template into the GJS bundle at build time — a `join(__dirname, …)`
|
|
21
|
+
* spelling silently loses that and ENOENTs under GJS.
|
|
22
|
+
*/
|
|
23
|
+
function loadDesktopTemplate() {
|
|
24
|
+
return readFileSync(new URL('../templates/app/desktop.tmpl', import.meta.url), 'utf-8');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Validate that the metadata has the minimum set of fields AppStream needs.
|
|
28
|
+
* Returns the missing fields with actionable hints; an empty list means OK.
|
|
29
|
+
*/
|
|
30
|
+
export function validateAppMetadata(inputs) {
|
|
31
|
+
const m = inputs.metadata;
|
|
32
|
+
const key = inputs.configKey;
|
|
33
|
+
const missing = [];
|
|
34
|
+
if (!m.developer?.id || !m.developer?.name) {
|
|
35
|
+
missing.push({
|
|
36
|
+
field: `${key}.developer`,
|
|
37
|
+
hint: `Set \`${key}.developer = { "id": "io.github.you", "name": "Your Name" }\` in package.json. The id is reverse-DNS.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (!m.summary) {
|
|
41
|
+
missing.push({
|
|
42
|
+
field: `${key}.summary`,
|
|
43
|
+
hint: 'One-line app summary, ≤80 chars, no trailing period. Example: "Learn 6502 assembly language".',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (!m.description) {
|
|
47
|
+
missing.push({
|
|
48
|
+
field: `${key}.description`,
|
|
49
|
+
hint: 'Plain text (split on blank lines) or DescriptionBlock[] for rich content with bullet lists + translator hints.',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (!m.license?.project) {
|
|
53
|
+
missing.push({
|
|
54
|
+
field: `${key}.license.project`,
|
|
55
|
+
hint: 'SPDX identifier of the project license, e.g. "MIT", "GPL-3.0-or-later".',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!m.homepageUrl) {
|
|
59
|
+
missing.push({
|
|
60
|
+
field: `${key}.homepageUrl`,
|
|
61
|
+
hint: 'Required by Flathub. Example: "https://github.com/you/your-repo".',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return missing;
|
|
65
|
+
}
|
|
66
|
+
/** Render the MetaInfo XML for a desktop application. */
|
|
67
|
+
export function renderMetainfoApp(inputs) {
|
|
68
|
+
return renderMetainfo(inputs, 'desktop-application');
|
|
69
|
+
}
|
|
70
|
+
/** Render the MetaInfo XML for a console application. */
|
|
71
|
+
export function renderMetainfoCli(inputs) {
|
|
72
|
+
return renderMetainfo(inputs, 'console-application');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Render the `.desktop` entry (app kind only).
|
|
76
|
+
*
|
|
77
|
+
* `Exec=` carries a field code when the app declares MIME types, because a
|
|
78
|
+
* desktop entry with `MimeType=` and no `%f`/`%u` is registered as a handler
|
|
79
|
+
* that is then launched with no argument — the file the user double-clicked is
|
|
80
|
+
* dropped on the floor, and `desktop-file-validate` says so. `x-scheme-handler/*`
|
|
81
|
+
* wins over file types: a URL handler must take `%u`.
|
|
82
|
+
*/
|
|
83
|
+
export function renderDesktopEntry(inputs) {
|
|
84
|
+
const m = inputs.metadata;
|
|
85
|
+
const categoriesLine = (m.categories ?? ['Utility']).join(';') + ';';
|
|
86
|
+
const keywordsLine = m.keywords?.length ? `Keywords=${m.keywords.join(';')};\n` : '';
|
|
87
|
+
// `MimeType=` is sourced from `<config>.provides.mimetypes` — the same field
|
|
88
|
+
// already populates `<mediatype>` entries in the MetaInfo XML, so callers
|
|
89
|
+
// configure both with one knob. Typical entries: `x-scheme-handler/<scheme>`
|
|
90
|
+
// for URL-scheme handlers, or `application/<mime>` for file-type handlers.
|
|
91
|
+
const mimetypes = m.provides?.mimetypes ?? [];
|
|
92
|
+
const mimetypesLine = mimetypes.length ? `MimeType=${mimetypes.join(';')};\n` : '';
|
|
93
|
+
return substitute(loadDesktopTemplate(), {
|
|
94
|
+
NAME: inputs.name,
|
|
95
|
+
SUMMARY: m.summary ?? inputs.name,
|
|
96
|
+
COMMAND: `${inputs.command}${execFieldCode(mimetypes)}`,
|
|
97
|
+
APP_ID: inputs.appId,
|
|
98
|
+
CATEGORIES_LINE: categoriesLine,
|
|
99
|
+
KEYWORDS_LINE: keywordsLine,
|
|
100
|
+
MIMETYPES_LINE: mimetypesLine,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** The `Exec=` field code implied by the declared MIME types. */
|
|
104
|
+
function execFieldCode(mimetypes) {
|
|
105
|
+
if (mimetypes.some((t) => t.startsWith('x-scheme-handler/')))
|
|
106
|
+
return ' %u';
|
|
107
|
+
if (mimetypes.length > 0)
|
|
108
|
+
return ' %f';
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
// ─── MetaInfo XML builder ────────────────────────────────────────────────
|
|
112
|
+
function renderMetainfo(inputs, kind) {
|
|
113
|
+
const m = inputs.metadata;
|
|
114
|
+
const year = inputs.copyrightYear ?? new Date().getFullYear();
|
|
115
|
+
const developerName = m.developer?.name ?? '';
|
|
116
|
+
const lines = [];
|
|
117
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
118
|
+
lines.push(`<!-- Copyright ${year} ${escapeXml(developerName)} -->`);
|
|
119
|
+
lines.push(`<component type="${kind}">`);
|
|
120
|
+
lines.push(` <id>${escapeXml(inputs.appId)}</id>`);
|
|
121
|
+
lines.push(` <metadata_license>${escapeXml(m.license?.metadata ?? 'CC0-1.0')}</metadata_license>`);
|
|
122
|
+
lines.push(` <project_license>${escapeXml(m.license?.project ?? '')}</project_license>`);
|
|
123
|
+
lines.push(` <name>${escapeXml(inputs.name)}</name>`);
|
|
124
|
+
pushTranslatorHint(lines, m.summaryTranslatorHint, ' ');
|
|
125
|
+
lines.push(` <summary>${escapeXml(m.summary ?? inputs.name)}</summary>`);
|
|
126
|
+
if (m.iconRemote) {
|
|
127
|
+
lines.push(` <icon type="remote">${escapeXml(m.iconRemote)}</icon>`);
|
|
128
|
+
}
|
|
129
|
+
// <description>
|
|
130
|
+
lines.push(' <description>');
|
|
131
|
+
for (const blockLine of renderDescriptionBlocks(m.description ?? '', ' ')) {
|
|
132
|
+
lines.push(blockLine);
|
|
133
|
+
}
|
|
134
|
+
lines.push(' </description>');
|
|
135
|
+
// <developer>
|
|
136
|
+
if (m.developer?.id && m.developer?.name) {
|
|
137
|
+
lines.push(` <developer id="${escapeXml(m.developer.id)}">`);
|
|
138
|
+
const translateAttr = m.developer.nameTranslatable === true ? '' : ' translate="no"';
|
|
139
|
+
lines.push(` <name${translateAttr}>${escapeXml(m.developer.name)}</name>`);
|
|
140
|
+
if (m.developer.email) {
|
|
141
|
+
lines.push(` <email>${escapeXml(m.developer.email)}</email>`);
|
|
142
|
+
}
|
|
143
|
+
lines.push(' </developer>');
|
|
144
|
+
}
|
|
145
|
+
if (kind === 'desktop-application') {
|
|
146
|
+
lines.push(` <launchable type="desktop-id">${escapeXml(inputs.appId)}.desktop</launchable>`);
|
|
147
|
+
}
|
|
148
|
+
// <screenshots>
|
|
149
|
+
if (m.screenshots?.length) {
|
|
150
|
+
lines.push(' <screenshots>');
|
|
151
|
+
m.screenshots.forEach((s, i) => {
|
|
152
|
+
const type = s.type ?? (i === 0 ? 'default' : undefined);
|
|
153
|
+
const typeAttr = type ? ` type="${escapeXml(type)}"` : '';
|
|
154
|
+
const envAttr = s.environment ? ` environment="${escapeXml(s.environment)}"` : '';
|
|
155
|
+
lines.push(` <screenshot${typeAttr}${envAttr}>`);
|
|
156
|
+
lines.push(` <image>${escapeXml(s.url)}</image>`);
|
|
157
|
+
if (s.caption) {
|
|
158
|
+
pushTranslatorHint(lines, s.captionTranslatorHint, ' ');
|
|
159
|
+
lines.push(` <caption>${escapeXml(s.caption)}</caption>`);
|
|
160
|
+
}
|
|
161
|
+
lines.push(' </screenshot>');
|
|
162
|
+
});
|
|
163
|
+
lines.push(' </screenshots>');
|
|
164
|
+
}
|
|
165
|
+
// <url> entries
|
|
166
|
+
if (m.homepageUrl)
|
|
167
|
+
lines.push(` <url type="homepage">${escapeXml(m.homepageUrl)}</url>`);
|
|
168
|
+
if (m.bugtrackerUrl)
|
|
169
|
+
lines.push(` <url type="bugtracker">${escapeXml(m.bugtrackerUrl)}</url>`);
|
|
170
|
+
if (m.vcsBrowserUrl)
|
|
171
|
+
lines.push(` <url type="vcs-browser">${escapeXml(m.vcsBrowserUrl)}</url>`);
|
|
172
|
+
if (m.donationUrl)
|
|
173
|
+
lines.push(` <url type="donation">${escapeXml(m.donationUrl)}</url>`);
|
|
174
|
+
if (m.translateUrl)
|
|
175
|
+
lines.push(` <url type="translate">${escapeXml(m.translateUrl)}</url>`);
|
|
176
|
+
// <content_rating>
|
|
177
|
+
const cr = normaliseContentRating(m.contentRating);
|
|
178
|
+
if (cr.attributes && Object.keys(cr.attributes).length > 0) {
|
|
179
|
+
lines.push(` <content_rating type="${escapeXml(cr.type)}">`);
|
|
180
|
+
for (const [key, value] of Object.entries(cr.attributes)) {
|
|
181
|
+
lines.push(` <content_attribute id="${escapeXml(key)}">${escapeXml(value)}</content_attribute>`);
|
|
182
|
+
}
|
|
183
|
+
lines.push(' </content_rating>');
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
lines.push(` <content_rating type="${escapeXml(cr.type)}" />`);
|
|
187
|
+
}
|
|
188
|
+
// <releases>
|
|
189
|
+
if (m.releases?.length) {
|
|
190
|
+
lines.push(' <releases>');
|
|
191
|
+
for (const r of m.releases) {
|
|
192
|
+
if (r.description === undefined) {
|
|
193
|
+
lines.push(` <release version="${escapeXml(r.version)}" date="${escapeXml(r.date)}" />`);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
lines.push(` <release version="${escapeXml(r.version)}" date="${escapeXml(r.date)}">`);
|
|
197
|
+
lines.push(' <description>');
|
|
198
|
+
for (const blockLine of renderDescriptionBlocks(r.description, ' ')) {
|
|
199
|
+
lines.push(blockLine);
|
|
200
|
+
}
|
|
201
|
+
lines.push(' </description>');
|
|
202
|
+
lines.push(' </release>');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
lines.push(' </releases>');
|
|
206
|
+
}
|
|
207
|
+
// <categories>
|
|
208
|
+
if (m.categories?.length) {
|
|
209
|
+
lines.push(' <categories>');
|
|
210
|
+
for (const c of m.categories)
|
|
211
|
+
lines.push(` <category>${escapeXml(c)}</category>`);
|
|
212
|
+
lines.push(' </categories>');
|
|
213
|
+
}
|
|
214
|
+
// <keywords>
|
|
215
|
+
if (m.keywords?.length) {
|
|
216
|
+
lines.push(' <keywords>');
|
|
217
|
+
for (const k of m.keywords)
|
|
218
|
+
lines.push(` <keyword>${escapeXml(k)}</keyword>`);
|
|
219
|
+
lines.push(' </keywords>');
|
|
220
|
+
}
|
|
221
|
+
// <branding> (apps only — Flathub ignores it on CLI)
|
|
222
|
+
if (kind === 'desktop-application' && m.branding) {
|
|
223
|
+
lines.push(' <branding>');
|
|
224
|
+
lines.push(` <color type="primary" scheme_preference="light">${escapeXml(m.branding.accentLight)}</color>`);
|
|
225
|
+
lines.push(` <color type="primary" scheme_preference="dark">${escapeXml(m.branding.accentDark)}</color>`);
|
|
226
|
+
lines.push(' </branding>');
|
|
227
|
+
}
|
|
228
|
+
// <kudos>
|
|
229
|
+
if (m.kudos?.length) {
|
|
230
|
+
lines.push(' <kudos>');
|
|
231
|
+
for (const k of m.kudos)
|
|
232
|
+
lines.push(` <kudo>${escapeXml(k)}</kudo>`);
|
|
233
|
+
lines.push(' </kudos>');
|
|
234
|
+
}
|
|
235
|
+
// <provides> — always emit <binary> for both kinds; <mediatype>/<dbus> only when configured
|
|
236
|
+
const binaries = m.provides?.binaries ?? [inputs.command];
|
|
237
|
+
const mimetypes = m.provides?.mimetypes ?? [];
|
|
238
|
+
const dbus = m.provides?.dbus ?? [];
|
|
239
|
+
if (binaries.length || mimetypes.length || dbus.length) {
|
|
240
|
+
lines.push(' <provides>');
|
|
241
|
+
for (const b of binaries)
|
|
242
|
+
lines.push(` <binary>${escapeXml(b)}</binary>`);
|
|
243
|
+
for (const t of mimetypes)
|
|
244
|
+
lines.push(` <mediatype>${escapeXml(t)}</mediatype>`);
|
|
245
|
+
for (const d of dbus)
|
|
246
|
+
lines.push(` <dbus type="${escapeXml(d.type)}">${escapeXml(d.id)}</dbus>`);
|
|
247
|
+
lines.push(' </provides>');
|
|
248
|
+
}
|
|
249
|
+
// <supports>
|
|
250
|
+
if (m.supports?.controls?.length || m.supports?.internet) {
|
|
251
|
+
lines.push(' <supports>');
|
|
252
|
+
for (const c of m.supports.controls ?? [])
|
|
253
|
+
lines.push(` <control>${escapeXml(c)}</control>`);
|
|
254
|
+
if (m.supports.internet)
|
|
255
|
+
lines.push(` <internet>${escapeXml(m.supports.internet)}</internet>`);
|
|
256
|
+
lines.push(' </supports>');
|
|
257
|
+
}
|
|
258
|
+
// <requires>
|
|
259
|
+
if (m.requires?.displayLengthMin || m.requires?.controls?.length || m.requires?.internet) {
|
|
260
|
+
lines.push(' <requires>');
|
|
261
|
+
if (m.requires.displayLengthMin) {
|
|
262
|
+
lines.push(` <display_length compare="ge">${m.requires.displayLengthMin}</display_length>`);
|
|
263
|
+
}
|
|
264
|
+
for (const c of m.requires.controls ?? [])
|
|
265
|
+
lines.push(` <control>${escapeXml(c)}</control>`);
|
|
266
|
+
if (m.requires.internet)
|
|
267
|
+
lines.push(` <internet>${escapeXml(m.requires.internet)}</internet>`);
|
|
268
|
+
lines.push(' </requires>');
|
|
269
|
+
}
|
|
270
|
+
// <recommends>
|
|
271
|
+
if (m.recommends?.displayLengthMin || m.recommends?.controls?.length) {
|
|
272
|
+
lines.push(' <recommends>');
|
|
273
|
+
if (m.recommends.displayLengthMin) {
|
|
274
|
+
lines.push(` <display_length compare="ge">${m.recommends.displayLengthMin}</display_length>`);
|
|
275
|
+
}
|
|
276
|
+
for (const c of m.recommends.controls ?? [])
|
|
277
|
+
lines.push(` <control>${escapeXml(c)}</control>`);
|
|
278
|
+
lines.push(' </recommends>');
|
|
279
|
+
}
|
|
280
|
+
lines.push('</component>');
|
|
281
|
+
return lines.join('\n') + '\n';
|
|
282
|
+
}
|
|
283
|
+
// ─── Description block renderer ──────────────────────────────────────────
|
|
284
|
+
function renderDescriptionBlocks(description, indent) {
|
|
285
|
+
const blocks = typeof description === 'string' ? stringToBlocks(description) : description;
|
|
286
|
+
const out = [];
|
|
287
|
+
for (const block of blocks) {
|
|
288
|
+
if ('p' in block) {
|
|
289
|
+
pushTranslatorHint(out, block.translatorHint, indent);
|
|
290
|
+
out.push(`${indent}<p>${escapeXml(block.p.trim().replace(/\s+/g, ' '))}</p>`);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
pushTranslatorHint(out, block.translatorHint, indent);
|
|
294
|
+
out.push(`${indent}<ul>`);
|
|
295
|
+
for (const item of block.ul) {
|
|
296
|
+
if (typeof item === 'string') {
|
|
297
|
+
out.push(`${indent} <li>${escapeXml(item)}</li>`);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
pushTranslatorHint(out, item.translatorHint, `${indent} `);
|
|
301
|
+
out.push(`${indent} <li>${escapeXml(item.item)}</li>`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
out.push(`${indent}</ul>`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
/** Auto-convert blank-line-split string into paragraph blocks. */
|
|
310
|
+
function stringToBlocks(s) {
|
|
311
|
+
return s
|
|
312
|
+
.trim()
|
|
313
|
+
.split(/\n\n+/)
|
|
314
|
+
.map((para) => ({ p: para.trim().replace(/\s+/g, ' ') }));
|
|
315
|
+
}
|
|
316
|
+
function pushTranslatorHint(out, hint, indent) {
|
|
317
|
+
if (!hint)
|
|
318
|
+
return;
|
|
319
|
+
out.push(`${indent}<!-- TRANSLATORS: ${hint} -->`);
|
|
320
|
+
}
|
|
321
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
322
|
+
function normaliseContentRating(cr) {
|
|
323
|
+
if (cr === undefined)
|
|
324
|
+
return { type: 'oars-1.1' };
|
|
325
|
+
if (typeof cr === 'string')
|
|
326
|
+
return { type: cr };
|
|
327
|
+
return { type: cr.type ?? 'oars-1.1', attributes: cr.attributes };
|
|
328
|
+
}
|
|
329
|
+
function substitute(template, tokens) {
|
|
330
|
+
let out = template;
|
|
331
|
+
for (const [key, value] of Object.entries(tokens)) {
|
|
332
|
+
out = out.split(`{{${key}}}`).join(value);
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
export function escapeXml(value) {
|
|
337
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
338
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Workspace } from '@gjsify/workspace';
|
|
2
|
+
export declare function moduleSpecifiers(code: string): string[];
|
|
3
|
+
export interface CliRuntimeClosureOptions {
|
|
4
|
+
/**
|
|
5
|
+
* File to start the walk from. Defaults to the running CLI's Node entry
|
|
6
|
+
* (`bin.gjsify`), which is what a spawned child loads.
|
|
7
|
+
*/
|
|
8
|
+
entry?: string | null;
|
|
9
|
+
/** The CLI's own manifest, for its optional peers. Read from disk by default. */
|
|
10
|
+
cliManifest?: {
|
|
11
|
+
peerDependencies?: Record<string, string>;
|
|
12
|
+
} | null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Workspace packages the running CLI imports, so a parallel sweep can build them
|
|
16
|
+
* first instead of underneath itself.
|
|
17
|
+
*
|
|
18
|
+
* Empty is the correct answer in three real situations and none of them is an
|
|
19
|
+
* error: a consumer project (its `@gjsify/*` deps are not workspaces, so nothing
|
|
20
|
+
* the sweep builds can tear), a relocated bundle with no reachable manifest, and
|
|
21
|
+
* a CLI whose entry has not been built.
|
|
22
|
+
*/
|
|
23
|
+
export declare function cliRuntimeClosure(workspaces: readonly Workspace[], options?: CliRuntimeClosureOptions): Set<string>;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Which workspace packages does the RUNNING CLI import at runtime?
|
|
2
|
+
//
|
|
3
|
+
// WHY THE SCHEDULER NEEDS THIS
|
|
4
|
+
//
|
|
5
|
+
// `gjsify foreach <script> -p` spawns children, and every child is another
|
|
6
|
+
// `gjsify` process: it BOOTS the CLI before it runs anything. So while a parallel
|
|
7
|
+
// sweep rebuilds the workspace, each child is reading the very `lib/` trees its
|
|
8
|
+
// siblings are writing. Measured on the macOS leg (run 31130155911,
|
|
9
|
+
// darwin-arm64), on the first full build that leg ever reached:
|
|
10
|
+
//
|
|
11
|
+
// [gjsify foreach] start @gjsify/native-platform (49/160 done, 3 in flight)
|
|
12
|
+
// [gjsify foreach] start @gjsify/npm-registry (50/160 done, 3 in flight)
|
|
13
|
+
// [@gjsify/native-platform] ERR_MODULE_NOT_FOUND
|
|
14
|
+
// .../npm-registry/lib/esm/_virtual/_rolldown/runtime.js
|
|
15
|
+
// imported from .../npm-registry/lib/esm/auth.js
|
|
16
|
+
//
|
|
17
|
+
// `auth.js` was on disk and its rolldown chunk was not — a torn write, not a
|
|
18
|
+
// corrupt build. `foreach` uses this set to build those packages BEFORE the
|
|
19
|
+
// parallel sweep starts, so nothing rewrites them while a child boots.
|
|
20
|
+
//
|
|
21
|
+
// WHY IT IS DERIVED AND NOT LISTED
|
|
22
|
+
//
|
|
23
|
+
// The four names a root script once carried by hand (`semver`, `npm-registry`,
|
|
24
|
+
// `tar`, `workspace`) were never the whole answer, and a hand-written list is the
|
|
25
|
+
// same defect one level up: it is right until an import changes. Two measured
|
|
26
|
+
// alternatives were rejected before this one:
|
|
27
|
+
//
|
|
28
|
+
// * the MANIFEST closure of `@gjsify/cli` (transitive workspace `dependencies`)
|
|
29
|
+
// selects 110 of the ~160 packages a full sweep builds — it counts the
|
|
30
|
+
// umbrella polyfill metas the CLI declares for the alias layer and never
|
|
31
|
+
// imports under Node. Serialising 110 packages is not a fix, it is the end of
|
|
32
|
+
// parallelism;
|
|
33
|
+
// * a regex over the sources reports 27, because `commands/storybook.ts` emits
|
|
34
|
+
// GENERATED CODE containing `import { … } from '@gjsify/storybook';` inside a
|
|
35
|
+
// template literal. A text scan cannot tell a program from a string, and that
|
|
36
|
+
// one string drags the whole GTK storybook subtree into the prefix.
|
|
37
|
+
//
|
|
38
|
+
// So the graph is PARSED, from the entry the children actually boot, and the
|
|
39
|
+
// answer was checked against ground truth: a Node loader hook recording every
|
|
40
|
+
// `file:` URL `gjsify --version` loads reports exactly the ten packages this walk
|
|
41
|
+
// returns (see `cli-runtime-closure.spec.ts` for the shape the walk must keep).
|
|
42
|
+
//
|
|
43
|
+
// THE ONE THING A PARSE CANNOT SEE
|
|
44
|
+
//
|
|
45
|
+
// A COMPUTED dynamic import. `bundler-pick.ts` loads the bundler engine as
|
|
46
|
+
// `await import(target)` where `target` is a resolved file URL, and
|
|
47
|
+
// `rolldown-plugin-gjsify`'s `css-as-string.ts` does the same for lightningcss —
|
|
48
|
+
// deliberately, so tsc and rolldown do not resolve an optional peer at build
|
|
49
|
+
// time. Those are invisible to any static walk, and they are invisible to npm for
|
|
50
|
+
// the same reason, which is exactly why the convention is to declare them as
|
|
51
|
+
// optional PEER dependencies. So the peers of every package in the graph are
|
|
52
|
+
// added: one declaration closes both blind spots, and it stays a declaration the
|
|
53
|
+
// package owns rather than a list this file keeps.
|
|
54
|
+
import * as acorn from 'acorn';
|
|
55
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
56
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
57
|
+
import { cliPackageDir } from './publish-headers.js';
|
|
58
|
+
/**
|
|
59
|
+
* Split a bare specifier into a WORKSPACE package name + its `exports` key, or
|
|
60
|
+
* `null` when it names no workspace.
|
|
61
|
+
*
|
|
62
|
+
* Matched against the workspace index rather than against the `@gjsify/` scope: a
|
|
63
|
+
* scope literal would be a second place that has to be edited when the project is
|
|
64
|
+
* vendored, forked or renamed, and the question here is only ever "is this one of
|
|
65
|
+
* OUR packages". Scoped names take two segments, unscoped one.
|
|
66
|
+
*/
|
|
67
|
+
function splitWorkspaceSpecifier(specifier, byName) {
|
|
68
|
+
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':'))
|
|
69
|
+
return null;
|
|
70
|
+
const parts = specifier.split('/');
|
|
71
|
+
const candidates = specifier.startsWith('@') ? [2] : [1];
|
|
72
|
+
for (const depth of candidates) {
|
|
73
|
+
const name = parts.slice(0, depth).join('/');
|
|
74
|
+
if (!byName.has(name))
|
|
75
|
+
continue;
|
|
76
|
+
const rest = parts.slice(depth).join('/');
|
|
77
|
+
return { name, subpath: rest ? `./${rest}` : '.' };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The string a module specifier node names, or `null` when it is computed.
|
|
83
|
+
*
|
|
84
|
+
* A substitution-free TEMPLATE LITERAL is as static as a quoted string, and
|
|
85
|
+
* skipping it is not hypothetical: the minifier rewrites `await
|
|
86
|
+
* import('gi://GLib?version=2.0')` to a backtick form, so a Literal-only reader
|
|
87
|
+
* silently loses every dynamic import in a built bundle.
|
|
88
|
+
*/
|
|
89
|
+
function literalSpecifier(node) {
|
|
90
|
+
if (node?.type === 'Literal')
|
|
91
|
+
return typeof node.value === 'string' ? node.value : null;
|
|
92
|
+
if (node?.type === 'TemplateLiteral' && node.expressions?.length === 0) {
|
|
93
|
+
const cooked = node.quasis?.[0]?.value?.cooked;
|
|
94
|
+
return typeof cooked === 'string' ? cooked : null;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
export function moduleSpecifiers(code) {
|
|
99
|
+
let ast;
|
|
100
|
+
try {
|
|
101
|
+
ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', allowHashBang: true });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const out = [];
|
|
107
|
+
const visit = (node) => {
|
|
108
|
+
if (!node || typeof node !== 'object')
|
|
109
|
+
return;
|
|
110
|
+
if (Array.isArray(node)) {
|
|
111
|
+
for (const child of node)
|
|
112
|
+
visit(child);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const n = node;
|
|
116
|
+
if (n.type === 'ImportDeclaration' ||
|
|
117
|
+
n.type === 'ExportNamedDeclaration' ||
|
|
118
|
+
n.type === 'ExportAllDeclaration' ||
|
|
119
|
+
n.type === 'ImportExpression') {
|
|
120
|
+
const specifier = literalSpecifier(n.source);
|
|
121
|
+
if (specifier !== null)
|
|
122
|
+
out.push(specifier);
|
|
123
|
+
}
|
|
124
|
+
for (const [key, value] of Object.entries(n)) {
|
|
125
|
+
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc')
|
|
126
|
+
continue;
|
|
127
|
+
visit(value);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
visit(ast);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/** The file a relative specifier names, probing the extensionless spellings Node accepts. */
|
|
134
|
+
function resolveRelative(fromFile, specifier) {
|
|
135
|
+
const base = resolvePath(dirname(fromFile), specifier);
|
|
136
|
+
for (const candidate of [base, `${base}.js`, `${base}.mjs`, join(base, 'index.js')]) {
|
|
137
|
+
try {
|
|
138
|
+
if (statSync(candidate).isFile())
|
|
139
|
+
return candidate;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
/* not this spelling */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The file a workspace package's `<subpath>` resolves to, read from its own
|
|
149
|
+
* manifest. `subpath` is `'.'` for a bare package specifier.
|
|
150
|
+
*
|
|
151
|
+
* The `exports` conditions are walked in the order the CHILD's runtime resolves
|
|
152
|
+
* them; `main`/`module` is the pre-`exports` fallback. Deliberately NOT
|
|
153
|
+
* `import.meta.resolve`: that answers only for the runtime doing the asking, and
|
|
154
|
+
* this walk must give the same answer from Node and from GJS, from a spec with a
|
|
155
|
+
* fixture tree as from a real install. Resolving the SUBPATH rather than the
|
|
156
|
+
* package root matters — `@gjsify/rolldown-plugin-gjsify/runtime` is a leaf, and
|
|
157
|
+
* substituting the root barrel for it would drag in everything the barrel
|
|
158
|
+
* re-exports and nothing imports.
|
|
159
|
+
*/
|
|
160
|
+
function workspaceEntry(ws, subpath) {
|
|
161
|
+
const manifest = ws.manifest;
|
|
162
|
+
const pick = (node) => {
|
|
163
|
+
if (typeof node === 'string')
|
|
164
|
+
return node;
|
|
165
|
+
if (!node || typeof node !== 'object' || Array.isArray(node))
|
|
166
|
+
return null;
|
|
167
|
+
const record = node;
|
|
168
|
+
for (const condition of ['node', 'import', 'default', 'gjs']) {
|
|
169
|
+
if (condition in record) {
|
|
170
|
+
const picked = pick(record[condition]);
|
|
171
|
+
if (picked)
|
|
172
|
+
return picked;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
};
|
|
177
|
+
let target = null;
|
|
178
|
+
if (typeof manifest.exports === 'string')
|
|
179
|
+
target = subpath === '.' ? manifest.exports : null;
|
|
180
|
+
else if (manifest.exports && typeof manifest.exports === 'object') {
|
|
181
|
+
target = pick(manifest.exports[subpath]);
|
|
182
|
+
}
|
|
183
|
+
if (!target && subpath === '.') {
|
|
184
|
+
target = typeof manifest.module === 'string' ? manifest.module : null;
|
|
185
|
+
target ??= typeof manifest.main === 'string' ? manifest.main : null;
|
|
186
|
+
}
|
|
187
|
+
if (!target)
|
|
188
|
+
return null;
|
|
189
|
+
const file = resolvePath(ws.location, target);
|
|
190
|
+
return existsSync(file) ? file : null;
|
|
191
|
+
}
|
|
192
|
+
/** `peerDependencies` of `manifest` that name a workspace package. */
|
|
193
|
+
function workspacePeers(manifest, byName) {
|
|
194
|
+
return Object.keys(manifest.peerDependencies ?? {}).filter((name) => byName.has(name));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Workspace packages the running CLI imports, so a parallel sweep can build them
|
|
198
|
+
* first instead of underneath itself.
|
|
199
|
+
*
|
|
200
|
+
* Empty is the correct answer in three real situations and none of them is an
|
|
201
|
+
* error: a consumer project (its `@gjsify/*` deps are not workspaces, so nothing
|
|
202
|
+
* the sweep builds can tear), a relocated bundle with no reachable manifest, and
|
|
203
|
+
* a CLI whose entry has not been built.
|
|
204
|
+
*/
|
|
205
|
+
export function cliRuntimeClosure(workspaces, options = {}) {
|
|
206
|
+
const byName = new Map(workspaces.map((ws) => [ws.name, ws]));
|
|
207
|
+
const found = new Set();
|
|
208
|
+
let entry = options.entry;
|
|
209
|
+
let cliManifest = options.cliManifest;
|
|
210
|
+
if (entry === undefined || cliManifest === undefined) {
|
|
211
|
+
const packageDir = cliPackageDir();
|
|
212
|
+
let manifest = null;
|
|
213
|
+
if (packageDir) {
|
|
214
|
+
try {
|
|
215
|
+
manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
/* unreadable — the closure degrades to empty, which is a no-op prefix */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
cliManifest ??= manifest;
|
|
222
|
+
if (entry === undefined) {
|
|
223
|
+
// The npm `bin` entry, NOT `gjsify.bin`: the GJS bundle is self-contained,
|
|
224
|
+
// so walking it would report nothing, while a GJS child still loads the
|
|
225
|
+
// engine facades through the peers below. The `lib/` graph is a superset
|
|
226
|
+
// of what either entry reaches, and one code path beats two.
|
|
227
|
+
const bin = manifest?.bin;
|
|
228
|
+
const relative = typeof bin === 'string' ? bin : (bin?.['gjsify'] ?? manifest?.main);
|
|
229
|
+
entry = packageDir && relative ? resolvePath(packageDir, relative) : null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const peer of workspacePeers(cliManifest ?? {}, byName))
|
|
233
|
+
found.add(peer);
|
|
234
|
+
if (!entry || !existsSync(entry))
|
|
235
|
+
return found;
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
const queue = [entry];
|
|
238
|
+
while (queue.length > 0) {
|
|
239
|
+
const file = queue.shift();
|
|
240
|
+
if (seen.has(file))
|
|
241
|
+
continue;
|
|
242
|
+
seen.add(file);
|
|
243
|
+
let code;
|
|
244
|
+
try {
|
|
245
|
+
code = readFileSync(file, 'utf8');
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
for (const specifier of moduleSpecifiers(code)) {
|
|
251
|
+
if (specifier.startsWith('.')) {
|
|
252
|
+
const next = resolveRelative(file, specifier);
|
|
253
|
+
if (next)
|
|
254
|
+
queue.push(next);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const split = splitWorkspaceSpecifier(specifier, byName);
|
|
258
|
+
const ws = split ? byName.get(split.name) : undefined;
|
|
259
|
+
if (!split || !ws)
|
|
260
|
+
continue;
|
|
261
|
+
found.add(split.name);
|
|
262
|
+
for (const peer of workspacePeers(ws.manifest, byName))
|
|
263
|
+
found.add(peer);
|
|
264
|
+
// Queued per SUBPATH, not per package: `seen` dedupes files, so a
|
|
265
|
+
// package imported through two subpaths is walked through both. A
|
|
266
|
+
// package-level visited set would stop at whichever subpath came
|
|
267
|
+
// first and miss what the other one reaches.
|
|
268
|
+
const next = workspaceEntry(ws, split.subpath);
|
|
269
|
+
if (next)
|
|
270
|
+
queue.push(next);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return found;
|
|
274
|
+
}
|
|
@@ -39,3 +39,16 @@ export interface PublishHeaderOptions {
|
|
|
39
39
|
*/
|
|
40
40
|
export declare function buildPublishHeaders(url: string, opts: PublishHeaderOptions): Record<string, string>;
|
|
41
41
|
export declare function cliVersion(): string;
|
|
42
|
+
/**
|
|
43
|
+
* Directory of the RUNNING `@gjsify/cli` package — the nearest ancestor of this
|
|
44
|
+
* module holding a `package.json` named `@gjsify/cli`; `null` when none.
|
|
45
|
+
*
|
|
46
|
+
* The climb, not a fixed `'..'` hop, for the reason {@link cliVersion} states: the
|
|
47
|
+
* package is dual-entry and the two entries sit at different depths.
|
|
48
|
+
* `GJSIFY_CLI_PACKAGE_JSON` overrides the starting point, same as for the version.
|
|
49
|
+
*
|
|
50
|
+
* Exported because the answer to "where am I installed" is the same question as
|
|
51
|
+
* "which version am I", and this file is where that climb lives — a second copy
|
|
52
|
+
* elsewhere is the drift `check-cli-own-version-read.mjs` exists to prevent.
|
|
53
|
+
*/
|
|
54
|
+
export declare function cliPackageDir(): string | null;
|