@hitslop/cli 0.1.3 → 0.2.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/README.md +9 -1
- package/dist/cli.js +822 -661
- package/dist/module-loader.js +25 -0
- package/package.json +17 -8
- package/templates/svelte/.agents/skills/hitslop-authoring/SKILL.md +15 -2
- package/templates/svelte/.agents/skills/hitslop-authoring/references/storage-and-packages.md +10 -4
- package/templates/svelte/.agents/skills/hitslop-authoring/references/workflow.md +23 -9
- package/templates/svelte/.agents/skills/hitslop-design/SKILL.md +3 -3
- package/templates/svelte/.agents/skills/hitslop-design/references/presentation-and-export.md +14 -11
- package/templates/svelte/AGENTS.md +10 -5
- package/templates/svelte/schema.ts +4 -4
- package/templates/svelte/src/App.svelte +24 -16
- package/templates/svelte/src/Export.svelte +9 -0
- package/templates/svelte/src/Icon.svelte +5 -0
- package/templates/svelte/src/Readout.svelte +13 -0
- package/templates/svelte/src/styles.css.ts +5 -4
- package/templates/svelte/theme.ts +12 -0
- package/templates/svelte/tsconfig.json +15 -0
- package/templates/svelte/assets/theme.css +0 -10
- package/templates/svelte/src/theme-contract.css.ts +0 -12
package/dist/cli.js
CHANGED
|
@@ -1,59 +1,69 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
3
18
|
|
|
4
|
-
// src/cli.ts
|
|
5
|
-
import { Crust } from "@crustjs/core";
|
|
6
|
-
import { join as join9 } from "path";
|
|
7
|
-
import { stat as stat7 } from "fs/promises";
|
|
8
|
-
import { createInterface } from "readline/promises";
|
|
9
|
-
import { SlopCategorySchema } from "@hitslop/schema";
|
|
10
|
-
|
|
11
|
-
// src/project.ts
|
|
12
|
-
import { cp, lstat as lstat2, mkdir as mkdir2, readFile as readFile3, readdir as readdir2, rm, stat as stat3, writeFile as writeFile2 } from "fs/promises";
|
|
13
|
-
import { basename, dirname as dirname2, join as join3, relative, resolve, sep } from "path";
|
|
14
|
-
import { fileURLToPath, pathToFileURL } from "url";
|
|
15
|
-
import { createHash } from "crypto";
|
|
16
|
-
import { zipSync } from "fflate";
|
|
17
|
-
import { decode as decodePng } from "fast-png";
|
|
18
|
-
import { build as viteBuild } from "vite";
|
|
19
|
-
import * as z from "zod";
|
|
20
|
-
import { manifestSchemaURL, parseManifest } from "@hitslop/schema";
|
|
21
19
|
// package.json
|
|
22
|
-
var package_default
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
20
|
+
var package_default;
|
|
21
|
+
var init_package = __esm(() => {
|
|
22
|
+
package_default = {
|
|
23
|
+
name: "@hitslop/cli",
|
|
24
|
+
version: "0.2.0",
|
|
25
|
+
description: "Create, develop, build, register, and publish hitSlop apps.",
|
|
26
|
+
license: "MIT",
|
|
27
|
+
repository: {
|
|
28
|
+
type: "git",
|
|
29
|
+
url: "git+https://github.com/hitslop/hitslop.git",
|
|
30
|
+
directory: "packages/cli"
|
|
31
|
+
},
|
|
32
|
+
homepage: "https://hitslop.com",
|
|
33
|
+
bugs: {
|
|
34
|
+
url: "https://github.com/hitslop/hitslop/issues"
|
|
35
|
+
},
|
|
36
|
+
keywords: ["hitslop", "cli", "local-first", "mini-apps"],
|
|
37
|
+
publishConfig: {
|
|
38
|
+
access: "public"
|
|
39
|
+
},
|
|
40
|
+
type: "module",
|
|
41
|
+
bin: {
|
|
42
|
+
slop: "dist/cli.js"
|
|
43
|
+
},
|
|
44
|
+
files: ["dist", "templates"],
|
|
45
|
+
scripts: {
|
|
46
|
+
build: "bun ../../scripts/clean-dist.ts && bun build src/cli.ts src/module-loader.ts --target bun --format esm --packages external --outdir dist && /bin/chmod +x dist/cli.js",
|
|
47
|
+
check: "tsc -p tsconfig.json",
|
|
48
|
+
test: "bun run --cwd ../schema build && bun test"
|
|
49
|
+
},
|
|
50
|
+
dependencies: {
|
|
51
|
+
"@crustjs/core": "^0.0.19",
|
|
52
|
+
"@hitslop/schema": "^0.2.0",
|
|
53
|
+
"@noble/ed25519": "^3.2.0",
|
|
54
|
+
"fast-png": "^8.0.0",
|
|
55
|
+
fflate: "^0.8.3",
|
|
56
|
+
parse5: "^8.0.1",
|
|
57
|
+
postcss: "^8.5.6",
|
|
58
|
+
vite: "^8.2.2"
|
|
59
|
+
},
|
|
60
|
+
devDependencies: {
|
|
61
|
+
"@types/bun": "latest",
|
|
62
|
+
typebox: "^1.3.26",
|
|
63
|
+
typescript: "^7.0.2"
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
});
|
|
57
67
|
|
|
58
68
|
// src/document-skill.ts
|
|
59
69
|
import { lstat, mkdir, readFile, readdir, stat, writeFile } from "fs/promises";
|
|
@@ -63,11 +73,8 @@ import {
|
|
|
63
73
|
documentGuideSource,
|
|
64
74
|
documentSkillContent,
|
|
65
75
|
documentSkillPath,
|
|
66
|
-
isCanonicalDocumentSkill,
|
|
67
76
|
maxDocumentGuideBytes
|
|
68
77
|
} from "@hitslop/schema";
|
|
69
|
-
var decoder = new TextDecoder("utf-8", { fatal: true });
|
|
70
|
-
var exists = async (path) => stat(path).then(() => true).catch(() => false);
|
|
71
78
|
async function emitDocumentSkill(sourceRoot, outputRoot) {
|
|
72
79
|
const skill = join(outputRoot, documentSkillPath);
|
|
73
80
|
await mkdir(dirname(skill), { recursive: true });
|
|
@@ -99,7 +106,7 @@ async function readDocumentGuideSource(sourceRoot) {
|
|
|
99
106
|
async function validateDocumentSkill(root) {
|
|
100
107
|
const agents = join(root, ".agents");
|
|
101
108
|
if (!await exists(agents)) {
|
|
102
|
-
|
|
109
|
+
return;
|
|
103
110
|
}
|
|
104
111
|
await requireDirectory(agents, ".agents");
|
|
105
112
|
await requireOnly(agents, new Set(["skills"]), ".agents");
|
|
@@ -115,14 +122,11 @@ async function validateDocumentSkill(root) {
|
|
|
115
122
|
});
|
|
116
123
|
if (!skillInfo?.isFile() || skillInfo.isSymbolicLink())
|
|
117
124
|
throw new Error(`${documentSkillPath} must be a regular file.`);
|
|
118
|
-
let canonical = false;
|
|
119
125
|
try {
|
|
120
|
-
|
|
126
|
+
decoder.decode(await readFile(skillPath));
|
|
121
127
|
} catch {
|
|
122
128
|
throw new Error(`${documentSkillPath} must be UTF-8.`);
|
|
123
129
|
}
|
|
124
|
-
if (!canonical)
|
|
125
|
-
throw new Error(`${documentSkillPath} is not a recognized canonical hitSlop document skill.`);
|
|
126
130
|
const references = join(folder, "references");
|
|
127
131
|
if (!await exists(references))
|
|
128
132
|
return;
|
|
@@ -157,13 +161,60 @@ async function requireOnly(directory, allowed, label, optionalReferences = false
|
|
|
157
161
|
}
|
|
158
162
|
}
|
|
159
163
|
}
|
|
164
|
+
var decoder, exists = async (path) => stat(path).then(() => true).catch(() => false);
|
|
165
|
+
var init_document_skill = __esm(() => {
|
|
166
|
+
decoder = new TextDecoder("utf-8", { fatal: true });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// src/evaluate-module.ts
|
|
170
|
+
import { fileURLToPath } from "url";
|
|
171
|
+
import { dirname as dirname2, resolve } from "path";
|
|
172
|
+
async function evaluateModule(path, kind, timeoutMs = 1e4) {
|
|
173
|
+
const absolute = resolve(path);
|
|
174
|
+
const loader = Bun.resolveSync("./module-loader", dirname2(fileURLToPath(import.meta.url)));
|
|
175
|
+
let receive;
|
|
176
|
+
const result = new Promise((resolve2) => {
|
|
177
|
+
receive = resolve2;
|
|
178
|
+
});
|
|
179
|
+
const child = Bun.spawn([process.execPath, loader, absolute, kind], {
|
|
180
|
+
stdin: "ignore",
|
|
181
|
+
stdout: process.stderr.fd,
|
|
182
|
+
stderr: process.stderr.fd,
|
|
183
|
+
ipc: (message) => receive(message)
|
|
184
|
+
});
|
|
185
|
+
let timer;
|
|
186
|
+
try {
|
|
187
|
+
const message = await Promise.race([
|
|
188
|
+
result,
|
|
189
|
+
child.exited.then((code) => {
|
|
190
|
+
throw new Error(`Loader exited without a result (status ${code})`);
|
|
191
|
+
}),
|
|
192
|
+
new Promise((_, reject) => {
|
|
193
|
+
timer = setTimeout(() => reject(new Error(`${kind} evaluation timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
194
|
+
})
|
|
195
|
+
]);
|
|
196
|
+
if (!message || typeof message !== "object" || !("ok" in message))
|
|
197
|
+
throw new Error("Invalid loader response");
|
|
198
|
+
if (message.ok === false && "error" in message && typeof message.error === "string")
|
|
199
|
+
throw new Error(message.error);
|
|
200
|
+
if (message.ok !== true || !("value" in message))
|
|
201
|
+
throw new Error("Invalid loader response");
|
|
202
|
+
return message.value;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new Error(`Could not evaluate ${kind}.ts (${absolute}): ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
205
|
+
} finally {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
child.disconnect();
|
|
208
|
+
child.kill("SIGKILL");
|
|
209
|
+
await child.exited;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
var init_evaluate_module = () => {};
|
|
160
213
|
|
|
161
214
|
// src/theme.ts
|
|
162
215
|
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
163
216
|
import { join as join2 } from "path";
|
|
164
217
|
import postcss from "postcss";
|
|
165
|
-
var exists2 = async (path) => stat2(path).then(() => true).catch(() => false);
|
|
166
|
-
var decoder2 = new TextDecoder("utf-8", { fatal: true });
|
|
167
218
|
async function readUTF8(path, label) {
|
|
168
219
|
try {
|
|
169
220
|
return decoder2.decode(await readFile2(path));
|
|
@@ -172,13 +223,31 @@ async function readUTF8(path, label) {
|
|
|
172
223
|
}
|
|
173
224
|
}
|
|
174
225
|
async function readThemeContract(root) {
|
|
226
|
+
const source = await readThemeCSS(root);
|
|
227
|
+
return themeContract(source);
|
|
228
|
+
}
|
|
229
|
+
function themeContract(source) {
|
|
230
|
+
if (source === undefined)
|
|
231
|
+
return;
|
|
232
|
+
const contract = parseTheme(source, "theme defaults");
|
|
233
|
+
validateThemeReferences(source, contract, "theme defaults");
|
|
234
|
+
return contract;
|
|
235
|
+
}
|
|
236
|
+
async function readThemeCSS(root) {
|
|
237
|
+
const definition = join2(root, "theme.ts");
|
|
175
238
|
const path = join2(root, "assets", "theme.css");
|
|
239
|
+
if (await exists2(definition)) {
|
|
240
|
+
if (await exists2(path))
|
|
241
|
+
throw new Error("Define theme defaults in theme.ts or assets/theme.css, not both.");
|
|
242
|
+
const css = await evaluateModule(definition, "theme");
|
|
243
|
+
if (typeof css !== "string")
|
|
244
|
+
throw new Error("theme.ts must default-export defineTheme(...)");
|
|
245
|
+
parseTheme(css, "theme.ts");
|
|
246
|
+
return css;
|
|
247
|
+
}
|
|
176
248
|
if (!await exists2(path))
|
|
177
249
|
return;
|
|
178
|
-
|
|
179
|
-
const contract = parseTheme(source, "assets/theme.css");
|
|
180
|
-
validateThemeReferences(source, contract, "assets/theme.css");
|
|
181
|
-
return contract;
|
|
250
|
+
return readUTF8(path, "assets/theme.css");
|
|
182
251
|
}
|
|
183
252
|
async function validateThemeOverride(root, contract) {
|
|
184
253
|
const path = join2(root, "stores", "theme.css");
|
|
@@ -232,213 +301,544 @@ function parseTheme(source, label) {
|
|
|
232
301
|
throw new Error(`${label} must define at least one --slop-* custom property.`);
|
|
233
302
|
return properties;
|
|
234
303
|
}
|
|
304
|
+
var exists2 = async (path) => stat2(path).then(() => true).catch(() => false), decoder2;
|
|
305
|
+
var init_theme = __esm(() => {
|
|
306
|
+
init_evaluate_module();
|
|
307
|
+
decoder2 = new TextDecoder("utf-8", { fatal: true });
|
|
308
|
+
});
|
|
235
309
|
|
|
236
310
|
// src/vite-build-plugin.ts
|
|
237
|
-
import {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
311
|
+
import { parse } from "parse5";
|
|
312
|
+
function elements(html) {
|
|
313
|
+
const result = [];
|
|
314
|
+
const visit = (node) => {
|
|
315
|
+
if ("tagName" in node)
|
|
316
|
+
result.push(node);
|
|
317
|
+
if ("childNodes" in node)
|
|
318
|
+
node.childNodes.forEach(visit);
|
|
319
|
+
};
|
|
320
|
+
visit(parse(html, { sourceCodeLocationInfo: true }));
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
function replaceElements(html, replace) {
|
|
324
|
+
for (const element of elements(html).reverse()) {
|
|
325
|
+
const replacement = replace(element);
|
|
326
|
+
const location = element.sourceCodeLocation;
|
|
327
|
+
if (replacement !== undefined && location)
|
|
328
|
+
html = html.slice(0, location.startOffset) + replacement + html.slice(location.endOffset);
|
|
329
|
+
}
|
|
330
|
+
return html;
|
|
244
331
|
}
|
|
245
332
|
function inlineStyle(html, fileName, css) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, `${links}</head>`) : links + html;
|
|
255
|
-
};
|
|
333
|
+
return replaceElements(html, (node) => {
|
|
334
|
+
if (node.tagName !== "link" || !matches(attribute(node, "href"), fileName))
|
|
335
|
+
return;
|
|
336
|
+
const media = attribute(node, "media");
|
|
337
|
+
const attributes = media ? ' media="' + media.replaceAll("&", "&").replaceAll('"', """) + '"' : "";
|
|
338
|
+
return "<style" + attributes + ">" + css.replace(/@charset\s+"UTF-8";/i, "").replace(/<\/style/gi, "<\\/style").trim() + "</style>";
|
|
339
|
+
});
|
|
340
|
+
}
|
|
256
341
|
function hitSlopBuildPlugin(options) {
|
|
257
|
-
const configure = (config) => {
|
|
258
|
-
config.base = "./";
|
|
259
|
-
config.build ??= {};
|
|
260
|
-
config.build.assetsInlineLimit = () => true;
|
|
261
|
-
config.build.chunkSizeWarningLimit = 1e8;
|
|
262
|
-
config.build.cssCodeSplit = false;
|
|
263
|
-
config.build.assetsDir = "";
|
|
264
|
-
config.build.rollupOptions ??= {};
|
|
265
|
-
config.build.rollupOptions.output ??= {};
|
|
266
|
-
const outputs = Array.isArray(config.build.rollupOptions.output) ? config.build.rollupOptions.output : [config.build.rollupOptions.output];
|
|
267
|
-
for (const output of outputs) {
|
|
268
|
-
if (Number.parseInt(viteVersion.split(".")[0] ?? "0", 10) >= 8) {
|
|
269
|
-
output.codeSplitting = false;
|
|
270
|
-
} else
|
|
271
|
-
output.inlineDynamicImports = true;
|
|
272
|
-
}
|
|
273
|
-
};
|
|
274
342
|
return {
|
|
275
343
|
name: "hitslop:build",
|
|
276
344
|
enforce: "post",
|
|
277
|
-
config:
|
|
345
|
+
config: () => ({ base: "./", build: { cssCodeSplit: false, assetsDir: "assets", sourcemap: false, assetsInlineLimit: 4096 } }),
|
|
278
346
|
generateBundle(_output, bundle) {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
347
|
+
for (const asset of Object.values(bundle)) {
|
|
348
|
+
if (asset.type !== "asset" || !asset.fileName.endsWith(".html"))
|
|
349
|
+
continue;
|
|
350
|
+
let html = String(asset.source);
|
|
351
|
+
for (const style of Object.values(bundle)) {
|
|
352
|
+
if (style.type !== "asset" || !style.fileName.endsWith(".css"))
|
|
353
|
+
continue;
|
|
354
|
+
const directory = style.fileName.slice(0, style.fileName.lastIndexOf("/") + 1);
|
|
355
|
+
const css = String(style.source).replace(/url\((['"]?)(\.\/)?([^)'"\s]+)\1\)/g, (match, quote, _dot, url) => /^(?:[a-z]+:|\/|#)/i.test(url) ? match : "url(" + quote + directory + url + quote + ")");
|
|
356
|
+
const updated = inlineStyle(html, style.fileName, css);
|
|
357
|
+
if (updated !== html) {
|
|
358
|
+
html = updated;
|
|
359
|
+
delete bundle[style.fileName];
|
|
360
|
+
}
|
|
288
361
|
}
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
362
|
+
if (options.hasTheme) {
|
|
363
|
+
const head = elements(html).find((node) => node.tagName === "head");
|
|
364
|
+
const offset = head?.sourceCodeLocation?.endTag?.startOffset ?? 0;
|
|
365
|
+
const links = '<link rel="stylesheet" href="assets/theme.css" data-hitslop-theme-default><link rel="stylesheet" href="theme.css" data-hitslop-theme>';
|
|
366
|
+
html = html.slice(0, offset) + links + html.slice(offset);
|
|
293
367
|
}
|
|
294
|
-
if (options.hasTheme)
|
|
295
|
-
html = addThemeLinks(html);
|
|
296
368
|
asset.source = html;
|
|
297
369
|
}
|
|
298
|
-
for (const fileName of consumed)
|
|
299
|
-
delete bundle[fileName];
|
|
300
370
|
}
|
|
301
371
|
};
|
|
302
372
|
}
|
|
373
|
+
var attribute = (node, name) => node.attrs.find((attr) => attr.name === name)?.value, matches = (value, file) => value?.replace(/^\.\//, "") === file;
|
|
374
|
+
var init_vite_build_plugin = () => {};
|
|
303
375
|
|
|
304
|
-
// src/
|
|
305
|
-
|
|
306
|
-
async function
|
|
307
|
-
|
|
308
|
-
|
|
376
|
+
// src/data-schema.ts
|
|
377
|
+
import { dataSchemaFromJSON } from "@hitslop/schema";
|
|
378
|
+
async function loadDataSchema(path, timeoutMs = 1e4) {
|
|
379
|
+
return dataSchemaFromJSON(await evaluateModule(path, "schema", timeoutMs));
|
|
380
|
+
}
|
|
381
|
+
var init_data_schema = __esm(() => {
|
|
382
|
+
init_evaluate_module();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// src/static-preview.ts
|
|
386
|
+
import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
387
|
+
import { checkPngDimensions } from "@hitslop/schema";
|
|
388
|
+
import { decode as decodePng, encode as encodePng } from "fast-png";
|
|
389
|
+
function validateStaticPng(bytes, label) {
|
|
390
|
+
if (bytes.byteLength === 0)
|
|
391
|
+
throw new Error(`${label} is empty.`);
|
|
392
|
+
if (bytes.byteLength > MAX_PREVIEW_BYTES)
|
|
393
|
+
throw new Error(`${label} cannot exceed 5 MiB.`);
|
|
394
|
+
checkPngDimensions(bytes, label);
|
|
395
|
+
try {
|
|
396
|
+
return decodePng(bytes, { checkCrc: true });
|
|
397
|
+
} catch {
|
|
398
|
+
throw new Error(`${label} must be a valid PNG image.`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function validateIconPng(bytes, label = "The template icon") {
|
|
402
|
+
checkPngDimensions(bytes, label, { width: ICON_SIZE, height: ICON_SIZE });
|
|
403
|
+
return validateStaticPng(bytes, label);
|
|
404
|
+
}
|
|
405
|
+
function iconFromPng(bytes, size = ICON_SIZE) {
|
|
406
|
+
const image = validateStaticPng(bytes, "The template preview");
|
|
407
|
+
const scale = size / Math.max(image.width, image.height);
|
|
408
|
+
const width = Math.max(1, Math.round(image.width * scale));
|
|
409
|
+
const height = Math.max(1, Math.round(image.height * scale));
|
|
410
|
+
const output = image.depth === 16 ? new Uint16Array(size * size * 4) : new Uint8Array(size * size * 4);
|
|
411
|
+
const alpha = image.depth === 16 ? 65535 : 255;
|
|
412
|
+
const offsetX = Math.floor((size - width) / 2);
|
|
413
|
+
const offsetY = Math.floor((size - height) / 2);
|
|
414
|
+
const samples = (position, sourceSize, targetSize) => {
|
|
415
|
+
const ratio = sourceSize / targetSize;
|
|
416
|
+
if (ratio < 1) {
|
|
417
|
+
const center = (position + 0.5) * ratio - 0.5;
|
|
418
|
+
const lower = Math.floor(center), fraction = center - lower;
|
|
419
|
+
return [[Math.max(0, Math.min(sourceSize - 1, lower)), 1 - fraction], [Math.max(0, Math.min(sourceSize - 1, lower + 1)), fraction]];
|
|
420
|
+
}
|
|
421
|
+
const start = position * ratio, end = (position + 1) * ratio;
|
|
422
|
+
const result = [];
|
|
423
|
+
for (let index = Math.floor(start);index < Math.ceil(end); index++) {
|
|
424
|
+
result.push([Math.min(sourceSize - 1, index), Math.max(0, Math.min(end, index + 1) - Math.max(start, index)) / ratio]);
|
|
425
|
+
}
|
|
426
|
+
return result;
|
|
427
|
+
};
|
|
428
|
+
const columns = Array.from({ length: width }, (_, x) => samples(x, image.width, width));
|
|
429
|
+
for (let y = 0;y < height; y += 1) {
|
|
430
|
+
const rows = samples(y, image.height, height);
|
|
431
|
+
for (let x = 0;x < width; x += 1) {
|
|
432
|
+
let red = 0, green = 0, blue = 0, opacity = 0;
|
|
433
|
+
for (const [sy, wy] of rows)
|
|
434
|
+
for (const [sx, wx] of columns[x]) {
|
|
435
|
+
const offset = (sy * image.width + sx) * image.channels;
|
|
436
|
+
const a = (image.channels === 2 || image.channels === 4 ? image.data[offset + image.channels - 1] : alpha) / alpha;
|
|
437
|
+
const weight = wx * wy * a;
|
|
438
|
+
const gray = image.channels <= 2;
|
|
439
|
+
red += image.data[offset] * weight;
|
|
440
|
+
green += image.data[offset + (gray ? 0 : 1)] * weight;
|
|
441
|
+
blue += image.data[offset + (gray ? 0 : 2)] * weight;
|
|
442
|
+
opacity += weight;
|
|
443
|
+
}
|
|
444
|
+
const destination = ((offsetY + y) * size + offsetX + x) * 4;
|
|
445
|
+
output[destination] = Math.round(opacity ? red / opacity : 0);
|
|
446
|
+
output[destination + 1] = Math.round(opacity ? green / opacity : 0);
|
|
447
|
+
output[destination + 2] = Math.round(opacity ? blue / opacity : 0);
|
|
448
|
+
output[destination + 3] = Math.round(opacity * alpha);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return encodePng({ width: size, height: size, data: output, depth: image.depth, channels: 4 });
|
|
452
|
+
}
|
|
453
|
+
async function writeDefaultIcon(previewPath, iconPath) {
|
|
454
|
+
await writeFile2(iconPath, iconFromPng(await readFile3(previewPath)));
|
|
455
|
+
}
|
|
456
|
+
var MAX_PREVIEW_BYTES, ICON_SIZE = 512;
|
|
457
|
+
var init_static_preview = __esm(() => {
|
|
458
|
+
MAX_PREVIEW_BYTES = 5 * 1024 * 1024;
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// src/runtime-package.ts
|
|
462
|
+
var exports_runtime_package = {};
|
|
463
|
+
__export(exports_runtime_package, {
|
|
464
|
+
validateRuntimePackage: () => validateRuntimePackage
|
|
465
|
+
});
|
|
466
|
+
import { Database } from "bun:sqlite";
|
|
467
|
+
import { forbiddenPackageNames, validateTemplatePath } from "@hitslop/schema";
|
|
468
|
+
import { lstat as lstat2, readFile as readFile4, readdir as readdir2, stat as stat3 } from "fs/promises";
|
|
469
|
+
import { basename, join as join3, relative, sep } from "path";
|
|
470
|
+
import { unzipSync } from "fflate";
|
|
471
|
+
import { decode as decodePng2 } from "fast-png";
|
|
472
|
+
import { compileDataSchema } from "@hitslop/schema";
|
|
473
|
+
async function validateRuntimePackage(root, options = {}) {
|
|
474
|
+
if (basename(root).startsWith("."))
|
|
475
|
+
throw new Error("Runtime package path is not valid.");
|
|
476
|
+
const rootInfo = await lstat2(root).catch(() => {
|
|
477
|
+
return;
|
|
478
|
+
});
|
|
479
|
+
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink())
|
|
480
|
+
throw new Error("A runtime .slop must be a directory, not a symlink.");
|
|
481
|
+
const manifest = await loadManifest(root);
|
|
482
|
+
await requireUTF8File(join3(root, "app.html"), "app.html");
|
|
483
|
+
await rejectSymlinks(root);
|
|
484
|
+
const allowed = new Set(["manifest.json", "app.html", "data.schema.json", "assets", "stores", "QuickLook", ".agents", "Icon\r"]);
|
|
485
|
+
for (const entry of await readdir2(root))
|
|
486
|
+
if (!allowed.has(entry))
|
|
487
|
+
throw new Error(`Runtime packages cannot contain ${entry}.`);
|
|
488
|
+
for (const path of await walk(root)) {
|
|
489
|
+
if (forbiddenPackageNames.has(basename(path).toLowerCase()))
|
|
490
|
+
throw new Error(`Runtime packages cannot contain ${relative(root, path)}.`);
|
|
491
|
+
if (options.template)
|
|
492
|
+
validateTemplatePath(relative(root, path).split(sep).join("/"));
|
|
493
|
+
}
|
|
494
|
+
if (options.template && await exists3(join3(root, "stores")))
|
|
495
|
+
throw new Error("Template packages cannot contain stores.");
|
|
496
|
+
if (options.template && await exists3(join3(root, "Icon\r")))
|
|
497
|
+
throw new Error("Template packages cannot contain Finder metadata Icon\\r.");
|
|
498
|
+
const schema = await readDataSchema(root);
|
|
499
|
+
const contract = await readThemeContract(root);
|
|
500
|
+
const appHTML = await readFile4(join3(root, "app.html"), "utf8");
|
|
501
|
+
validateThemeReferences(appHTML, contract, "app.html");
|
|
502
|
+
await validateStores(root, schema, contract);
|
|
503
|
+
await validateQuickLook(root, options.requirePreview ?? false);
|
|
504
|
+
await validateDocumentSkill(root);
|
|
309
505
|
return manifest;
|
|
310
506
|
}
|
|
311
|
-
|
|
507
|
+
async function readDataSchema(root) {
|
|
508
|
+
const current = join3(root, "data.schema.json");
|
|
509
|
+
if (!await exists3(current))
|
|
510
|
+
return;
|
|
511
|
+
await requireUTF8File(current, "data.schema.json");
|
|
512
|
+
try {
|
|
513
|
+
const value = JSON.parse(await readFile4(current, "utf8"));
|
|
514
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
515
|
+
throw new Error("schema root must be an object");
|
|
516
|
+
compileDataSchema(value);
|
|
517
|
+
return value;
|
|
518
|
+
} catch (error) {
|
|
519
|
+
throw new Error(`data.schema.json must contain valid JSON Schema: ${error instanceof Error ? error.message : String(error)}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function validateStores(root, schema, contract) {
|
|
523
|
+
const stores = join3(root, "stores");
|
|
524
|
+
if (!await exists3(stores))
|
|
525
|
+
return;
|
|
526
|
+
const info = await lstat2(stores);
|
|
527
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
528
|
+
throw new Error("stores must be a directory.");
|
|
529
|
+
const allowed = new Set(["data.json", "data.sqlite", "data.sqlite-wal", "data.sqlite-shm", "media", "theme.css"]);
|
|
530
|
+
for (const entry of await readdir2(stores, { withFileTypes: true })) {
|
|
531
|
+
if (!allowed.has(entry.name))
|
|
532
|
+
throw new Error(`Unexpected store entry stores/${entry.name}.`);
|
|
533
|
+
if (entry.name === "media" ? !entry.isDirectory() : !entry.isFile())
|
|
534
|
+
throw new Error(`Invalid store entry stores/${entry.name}.`);
|
|
535
|
+
}
|
|
536
|
+
const json = join3(stores, "data.json");
|
|
537
|
+
if (await exists3(json)) {
|
|
538
|
+
let value;
|
|
539
|
+
try {
|
|
540
|
+
value = JSON.parse(decoder3.decode(await readFile4(json)));
|
|
541
|
+
} catch (error) {
|
|
542
|
+
throw new Error(`stores/data.json must be valid UTF-8 JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
543
|
+
}
|
|
544
|
+
if (schema) {
|
|
545
|
+
let validator;
|
|
546
|
+
try {
|
|
547
|
+
validator = compileDataSchema(schema);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
throw new Error(`The data schema is not supported: ${error instanceof Error ? error.message : String(error)}`);
|
|
550
|
+
}
|
|
551
|
+
validator(value);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const sqlite = join3(stores, "data.sqlite");
|
|
555
|
+
for (const sidecar of ["data.sqlite-wal", "data.sqlite-shm"]) {
|
|
556
|
+
if (await exists3(join3(stores, sidecar)) && !await exists3(sqlite))
|
|
557
|
+
throw new Error(`stores/${sidecar} requires stores/data.sqlite.`);
|
|
558
|
+
}
|
|
559
|
+
if (await exists3(sqlite))
|
|
560
|
+
validateSQLite(sqlite);
|
|
561
|
+
const media = join3(stores, "media");
|
|
562
|
+
if (await exists3(media))
|
|
563
|
+
await validateMedia(media);
|
|
564
|
+
await validateThemeOverride(root, contract);
|
|
565
|
+
}
|
|
566
|
+
function validateSQLite(path) {
|
|
567
|
+
let database;
|
|
568
|
+
try {
|
|
569
|
+
database = new Database(path, { readonly: true, strict: true });
|
|
570
|
+
const rows = database.query("PRAGMA quick_check").all();
|
|
571
|
+
if (rows.length !== 1 || Object.values(rows[0] ?? {})[0] !== "ok")
|
|
572
|
+
throw new Error("PRAGMA quick_check did not return ok");
|
|
573
|
+
} catch (error) {
|
|
574
|
+
throw new Error(`stores/data.sqlite failed its integrity check: ${error instanceof Error ? error.message : String(error)}`);
|
|
575
|
+
} finally {
|
|
576
|
+
database?.close();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async function validateMedia(directory) {
|
|
580
|
+
const entries = await readdir2(directory, { withFileTypes: true });
|
|
581
|
+
for (const entry of entries) {
|
|
582
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
583
|
+
throw new Error(`Named media must be regular files: stores/media/${entry.name}.`);
|
|
584
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(entry.name))
|
|
585
|
+
throw new Error(`Invalid named media key: ${entry.name}.`);
|
|
586
|
+
const bytes = await readFile4(join3(directory, entry.name));
|
|
587
|
+
if (!isImage(bytes) && !isBoundedZip(bytes))
|
|
588
|
+
throw new Error(`Unsupported named media content: ${entry.name}.`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function isImage(bytes) {
|
|
592
|
+
if (bytes.byteLength > 25 * MiB)
|
|
593
|
+
return false;
|
|
594
|
+
if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
|
|
595
|
+
try {
|
|
596
|
+
decodePng2(bytes, { checkCrc: true });
|
|
597
|
+
return true;
|
|
598
|
+
} catch {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (bytes[0] === 255 && bytes[1] === 216 && bytes.at(-2) === 255 && bytes.at(-1) === 217)
|
|
603
|
+
return true;
|
|
604
|
+
if (String.fromCharCode(...bytes.subarray(0, 6)).match(/^GIF8[79]a$/))
|
|
605
|
+
return true;
|
|
606
|
+
return String.fromCharCode(...bytes.subarray(0, 4)) === "RIFF" && String.fromCharCode(...bytes.subarray(8, 12)) === "WEBP";
|
|
607
|
+
}
|
|
608
|
+
function isBoundedZip(bytes) {
|
|
609
|
+
if (bytes.byteLength > 10 * MiB || bytes[0] !== 80 || bytes[1] !== 75)
|
|
610
|
+
return false;
|
|
312
611
|
try {
|
|
313
|
-
|
|
612
|
+
const files = unzipSync(bytes);
|
|
613
|
+
const names = Object.keys(files);
|
|
614
|
+
if (!names.length || names.length > 256)
|
|
615
|
+
return false;
|
|
616
|
+
let total = 0;
|
|
617
|
+
for (const [name, value] of Object.entries(files)) {
|
|
618
|
+
if (!safeArchivePath(name) || value.byteLength > 25 * MiB)
|
|
619
|
+
return false;
|
|
620
|
+
total += value.byteLength;
|
|
621
|
+
if (total > 50 * MiB)
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
314
624
|
return true;
|
|
315
625
|
} catch {
|
|
316
626
|
return false;
|
|
317
627
|
}
|
|
318
|
-
}
|
|
319
|
-
|
|
628
|
+
}
|
|
629
|
+
async function validateQuickLook(root, required) {
|
|
630
|
+
const directory = join3(root, "QuickLook");
|
|
631
|
+
if (!await exists3(directory)) {
|
|
632
|
+
if (required)
|
|
633
|
+
throw new Error("Template packages must contain QuickLook/Preview.png and QuickLook/Icon.png.");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const info = await lstat2(directory);
|
|
637
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
638
|
+
throw new Error("QuickLook must be a directory.");
|
|
639
|
+
const allowed = new Set(["Preview.png", "Icon.png"]);
|
|
640
|
+
for (const entry of await readdir2(directory))
|
|
641
|
+
if (!allowed.has(entry))
|
|
642
|
+
throw new Error(`Unexpected QuickLook entry QuickLook/${entry}.`);
|
|
643
|
+
const preview = join3(directory, "Preview.png");
|
|
644
|
+
const icon = join3(directory, "Icon.png");
|
|
645
|
+
if (required && (!await exists3(preview) || !await exists3(icon)))
|
|
646
|
+
throw new Error("Template packages must contain QuickLook/Preview.png and QuickLook/Icon.png.");
|
|
647
|
+
if (await exists3(preview))
|
|
648
|
+
validateStaticPng(await readFile4(preview), "QuickLook/Preview.png");
|
|
649
|
+
if (await exists3(icon))
|
|
650
|
+
validateIconPng(await readFile4(icon));
|
|
651
|
+
}
|
|
652
|
+
async function requireUTF8File(path, label) {
|
|
653
|
+
const info = await lstat2(path).catch(() => {
|
|
654
|
+
return;
|
|
655
|
+
});
|
|
656
|
+
if (!info?.isFile() || info.isSymbolicLink())
|
|
657
|
+
throw new Error(`${label} must be a regular file.`);
|
|
658
|
+
try {
|
|
659
|
+
decoder3.decode(await readFile4(path));
|
|
660
|
+
} catch {
|
|
661
|
+
throw new Error(`${label} must be UTF-8.`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
async function rejectSymlinks(root) {
|
|
665
|
+
for (const path of await walk(root))
|
|
666
|
+
if ((await lstat2(path)).isSymbolicLink())
|
|
667
|
+
throw new Error(`Runtime packages cannot contain symlinks: ${relative(root, path)}.`);
|
|
668
|
+
}
|
|
669
|
+
async function walk(root, directory = root) {
|
|
670
|
+
const result = [];
|
|
671
|
+
for (const entry of await readdir2(directory, { withFileTypes: true })) {
|
|
672
|
+
const path = join3(directory, entry.name);
|
|
673
|
+
result.push(path);
|
|
674
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
675
|
+
result.push(...await walk(root, path));
|
|
676
|
+
}
|
|
677
|
+
return result.sort((left, right) => relative(root, left).split(sep).join("/").localeCompare(relative(root, right).split(sep).join("/")));
|
|
678
|
+
}
|
|
679
|
+
var decoder3, exists3 = async (path) => stat3(path).then(() => true).catch(() => false), MiB, safeArchivePath = (path) => Boolean(path) && !path.startsWith("/") && !path.includes("\\") && !path.includes("\x00") && path.replace(/\/$/, "").split("/").every((part) => part && part !== "." && part !== "..");
|
|
680
|
+
var init_runtime_package = __esm(() => {
|
|
681
|
+
init_document_skill();
|
|
682
|
+
init_project();
|
|
683
|
+
init_static_preview();
|
|
684
|
+
init_theme();
|
|
685
|
+
decoder3 = new TextDecoder("utf-8", { fatal: true });
|
|
686
|
+
MiB = 1024 * 1024;
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// src/project.ts
|
|
690
|
+
import { cp, lstat as lstat3, mkdir as mkdir2, mkdtemp, readFile as readFile5, readdir as readdir3, rename, rm, stat as stat4, writeFile as writeFile3 } from "fs/promises";
|
|
691
|
+
import { basename as basename2, dirname as dirname3, join as join4, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
|
|
692
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
693
|
+
import { createHash } from "crypto";
|
|
694
|
+
import { zipSync } from "fflate";
|
|
695
|
+
import { decode as decodePng3 } from "fast-png";
|
|
696
|
+
import { build as viteBuild } from "vite";
|
|
697
|
+
import { checkPngDimensions as checkPngDimensions2, manifestSchemaURL, parseManifest } from "@hitslop/schema";
|
|
698
|
+
async function loadManifest(root) {
|
|
699
|
+
const manifest = parseManifest(JSON.parse(await readFile5(manifestPath(root), "utf8")));
|
|
700
|
+
await validateSkin(root, manifest);
|
|
701
|
+
return manifest;
|
|
702
|
+
}
|
|
320
703
|
async function validateSkin(root, manifest) {
|
|
321
704
|
if (!("skin" in manifest.presentation))
|
|
322
705
|
return;
|
|
323
|
-
const path =
|
|
324
|
-
if (!await
|
|
706
|
+
const path = join4(root, manifest.presentation.skin);
|
|
707
|
+
if (!await exists4(path))
|
|
325
708
|
throw new Error(`Missing window skin: ${manifest.presentation.skin}`);
|
|
326
|
-
const info = await
|
|
709
|
+
const info = await lstat3(path);
|
|
327
710
|
if (!info.isFile() || info.isSymbolicLink())
|
|
328
711
|
throw new Error("The window skin must be a regular file, not a symlink.");
|
|
329
712
|
let png;
|
|
713
|
+
const bytes = await readFile5(path);
|
|
714
|
+
checkPngDimensions2(bytes, "The window skin", manifest.presentation);
|
|
330
715
|
try {
|
|
331
|
-
png =
|
|
716
|
+
png = decodePng3(bytes, { checkCrc: true });
|
|
332
717
|
} catch {
|
|
333
718
|
throw new Error("The window skin must be a valid PNG image.");
|
|
334
719
|
}
|
|
335
|
-
if (png.width !== manifest.presentation.width || png.height !== manifest.presentation.height) {
|
|
336
|
-
throw new Error(`The window skin must be exactly ${manifest.presentation.width}x${manifest.presentation.height} pixels; received ${png.width}x${png.height}.`);
|
|
337
|
-
}
|
|
338
720
|
if (png.channels !== 4)
|
|
339
721
|
throw new Error("The window skin must be an RGBA PNG image.");
|
|
340
722
|
}
|
|
341
|
-
async function scaffold(destination, options
|
|
723
|
+
async function scaffold(destination, options) {
|
|
342
724
|
const template = options.template ?? "svelte-counter";
|
|
343
725
|
if (!new Set(["svelte", "svelte-counter"]).has(template))
|
|
344
726
|
throw new Error(`Unknown authoring template: ${template}`);
|
|
345
|
-
if (await
|
|
727
|
+
if (await exists4(destination) && (await readdir3(destination)).length)
|
|
346
728
|
throw new Error(`Destination is not empty: ${destination}`);
|
|
347
|
-
const slug =
|
|
729
|
+
const slug = basename2(destination).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "my-slop";
|
|
348
730
|
const defaultTitle = slug.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
|
|
349
731
|
const title = options.title || defaultTitle;
|
|
350
|
-
const templateRoot =
|
|
732
|
+
const templateRoot = fileURLToPath2(new URL("../templates/svelte", import.meta.url));
|
|
351
733
|
await cp(templateRoot, destination, { recursive: true });
|
|
352
|
-
await
|
|
734
|
+
await writeFile3(join4(destination, "package.json"), JSON.stringify({
|
|
353
735
|
name: slug,
|
|
354
736
|
private: true,
|
|
355
737
|
type: "module",
|
|
356
738
|
scripts: {
|
|
357
739
|
dev: "slop dev",
|
|
740
|
+
check: "svelte-check --tsgo --tsconfig tsconfig.json",
|
|
358
741
|
validate: "slop validate",
|
|
359
742
|
build: "slop build",
|
|
360
743
|
register: "slop register",
|
|
361
744
|
publish: "slop publish"
|
|
362
745
|
},
|
|
363
|
-
dependencies: { "@hitslop/runtime": "^0.
|
|
364
|
-
devDependencies: { "@hitslop/cli": `^${package_default.version}`, "@sveltejs/vite-plugin-svelte": "^7.
|
|
746
|
+
dependencies: { "@hitslop/runtime": "^0.2.0", "@hitslop/svelte": "^0.2.0", "bits-ui": "^2.19.0", svelte: "^5.57.0", typebox: "^1.3.26" },
|
|
747
|
+
devDependencies: { "@hitslop/cli": `^${package_default.version}`, "@sveltejs/vite-plugin-svelte": "^7.3.0", "@vanilla-extract/css": "^1.21.2", "@vanilla-extract/vite-plugin": "^5.2.6", vite: "^8.2.2", "svelte-check": "^4.7.6", "@typescript/native": "npm:typescript@^7.0.2", typescript: "^6.0.3" }
|
|
365
748
|
}, null, 2) + `
|
|
366
749
|
`);
|
|
367
|
-
|
|
750
|
+
const manifest = parseManifest({
|
|
368
751
|
$schema: manifestSchemaURL,
|
|
369
752
|
slug,
|
|
753
|
+
author: options.author,
|
|
370
754
|
title,
|
|
371
755
|
description: options.description || "A small, lovable hitSlop app.",
|
|
372
756
|
categories: options.categories?.length ? options.categories : ["utilities"],
|
|
373
757
|
presentation: { width: 560, height: 420 }
|
|
374
|
-
}
|
|
758
|
+
});
|
|
759
|
+
await writeFile3(join4(destination, "manifest.json"), JSON.stringify(manifest, null, 2) + `
|
|
375
760
|
`);
|
|
376
|
-
const appPath =
|
|
377
|
-
await
|
|
761
|
+
const appPath = join4(destination, "src/App.svelte");
|
|
762
|
+
await writeFile3(appPath, (await readFile5(appPath, "utf8")).replace("__SLOP_TITLE_LITERAL__", JSON.stringify(title)));
|
|
378
763
|
}
|
|
379
764
|
async function buildSlop(root) {
|
|
380
765
|
const manifest = await loadManifest(root);
|
|
381
|
-
const
|
|
766
|
+
const schemaPath = join4(root, "schema.ts");
|
|
767
|
+
const dataSchema = await exists4(schemaPath) ? await loadDataSchema(schemaPath) : undefined;
|
|
382
768
|
await readDocumentGuideSource(root);
|
|
383
|
-
const distRoot =
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
769
|
+
const distRoot = join4(root, "dist");
|
|
770
|
+
await mkdir2(distRoot, { recursive: true });
|
|
771
|
+
const staging = await mkdtemp(join4(distRoot, ".build-"));
|
|
772
|
+
const output = join4(staging, `${manifest.slug}.slop`);
|
|
773
|
+
const destination = join4(distRoot, `${manifest.slug}.slop`);
|
|
774
|
+
const viteOut = join4(staging, "vite");
|
|
775
|
+
try {
|
|
776
|
+
const themeCSS = await readThemeCSS(root);
|
|
777
|
+
const contract = themeContract(themeCSS);
|
|
778
|
+
const hasTheme = contract !== undefined;
|
|
779
|
+
await mkdir2(output, { recursive: true });
|
|
780
|
+
await viteBuild({ root, plugins: [hitSlopBuildPlugin({ hasTheme })], build: { outDir: viteOut, emptyOutDir: true } });
|
|
781
|
+
const html = join4(viteOut, "index.html");
|
|
782
|
+
if (!await exists4(html))
|
|
783
|
+
throw new Error("Vite did not produce index.html");
|
|
784
|
+
validateThemeReferences(await readFile5(html, "utf8"), contract, "generated app.html");
|
|
785
|
+
await cp(html, join4(output, "app.html"));
|
|
786
|
+
for (const entry of await readdir3(viteOut)) {
|
|
787
|
+
if (entry === "index.html")
|
|
788
|
+
continue;
|
|
789
|
+
if (entry !== "assets")
|
|
790
|
+
throw new Error(`Unsupported Vite output ${entry}; emit runtime resources under assets/.`);
|
|
791
|
+
await copyRuntimeTree(join4(viteOut, entry), join4(output, entry));
|
|
792
|
+
}
|
|
793
|
+
await writeFile3(join4(output, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
398
794
|
`);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
795
|
+
for (const name of ["assets"]) {
|
|
796
|
+
const source = join4(root, name);
|
|
797
|
+
if (await exists4(source))
|
|
798
|
+
await copyRuntimeTree(source, join4(output, name));
|
|
799
|
+
}
|
|
800
|
+
if (themeCSS !== undefined) {
|
|
801
|
+
await mkdir2(join4(output, "assets"), { recursive: true });
|
|
802
|
+
await writeFile3(join4(output, "assets/theme.css"), themeCSS);
|
|
803
|
+
}
|
|
804
|
+
if (dataSchema)
|
|
805
|
+
await writeFile3(join4(output, "data.schema.json"), `${JSON.stringify(dataSchema, null, 2)}
|
|
406
806
|
`);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
|
|
807
|
+
await emitDocumentSkill(root, output);
|
|
808
|
+
const { validateRuntimePackage: validateRuntimePackage2 } = await Promise.resolve().then(() => (init_runtime_package(), exports_runtime_package));
|
|
809
|
+
await validateRuntimePackage2(output, { template: true });
|
|
810
|
+
const previous = join4(staging, "previous.slop");
|
|
811
|
+
if (await exists4(destination))
|
|
812
|
+
await rename(destination, previous);
|
|
813
|
+
try {
|
|
814
|
+
await rename(output, destination);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (await exists4(previous))
|
|
817
|
+
await rename(previous, destination);
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
820
|
+
return { directory: destination, manifest };
|
|
821
|
+
} finally {
|
|
822
|
+
await rm(staging, { recursive: true, force: true });
|
|
423
823
|
}
|
|
424
|
-
return schema;
|
|
425
824
|
}
|
|
426
825
|
async function validateAuthoringProject(root) {
|
|
427
826
|
const manifest = await loadManifest(root);
|
|
428
827
|
await readThemeContract(root);
|
|
429
|
-
await
|
|
828
|
+
if (await exists4(join4(root, "schema.ts")))
|
|
829
|
+
await loadDataSchema(join4(root, "schema.ts"));
|
|
430
830
|
await readDocumentGuideSource(root);
|
|
431
831
|
return manifest;
|
|
432
832
|
}
|
|
433
|
-
async function
|
|
833
|
+
async function walk2(root, directory = root) {
|
|
434
834
|
const result = [];
|
|
435
|
-
for (const entry of await
|
|
436
|
-
const absolute =
|
|
437
|
-
const rel =
|
|
835
|
+
for (const entry of await readdir3(directory, { withFileTypes: true })) {
|
|
836
|
+
const absolute = join4(directory, entry.name);
|
|
837
|
+
const rel = relative2(root, absolute).split(sep2).join("/");
|
|
438
838
|
if (entry.isSymbolicLink())
|
|
439
839
|
throw new Error(`Symlinks are not allowed in artifacts: ${rel}`);
|
|
440
840
|
if (entry.isDirectory())
|
|
441
|
-
result.push(...await
|
|
841
|
+
result.push(...await walk2(root, absolute));
|
|
442
842
|
else if (entry.isFile())
|
|
443
843
|
result.push(rel);
|
|
444
844
|
}
|
|
@@ -447,33 +847,73 @@ async function walk(root, directory = root) {
|
|
|
447
847
|
async function packSlop(directory) {
|
|
448
848
|
const files = {};
|
|
449
849
|
const epoch = new Date(2000, 0, 1, 0, 0, 0);
|
|
450
|
-
for (const path of await
|
|
451
|
-
files[path] = [new Uint8Array(await Bun.file(
|
|
850
|
+
for (const path of await walk2(directory))
|
|
851
|
+
files[path] = [new Uint8Array(await Bun.file(join4(directory, path)).arrayBuffer()), { mtime: epoch, level: 9 }];
|
|
452
852
|
const bytes = zipSync(files, { level: 9 });
|
|
453
853
|
return { bytes, sha256: sha256(bytes) };
|
|
454
854
|
}
|
|
455
855
|
async function copyRuntimeTree(source, destination) {
|
|
456
|
-
if ((await
|
|
856
|
+
if ((await lstat3(source)).isSymbolicLink())
|
|
457
857
|
throw new Error(`Symlinks are not allowed in artifacts: ${source}`);
|
|
458
|
-
const info = await
|
|
858
|
+
const info = await stat4(source);
|
|
459
859
|
if (info.isDirectory()) {
|
|
460
860
|
await mkdir2(destination, { recursive: true });
|
|
461
|
-
for (const entry of await
|
|
462
|
-
await copyRuntimeTree(
|
|
861
|
+
for (const entry of await readdir3(source))
|
|
862
|
+
await copyRuntimeTree(join4(source, entry), join4(destination, entry));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
await mkdir2(dirname3(destination), { recursive: true });
|
|
866
|
+
if (await exists4(destination)) {
|
|
867
|
+
if (!(await readFile5(source)).equals(await readFile5(destination)))
|
|
868
|
+
throw new Error(`Runtime asset collision: ${destination}`);
|
|
463
869
|
return;
|
|
464
870
|
}
|
|
465
|
-
await mkdir2(dirname2(destination), { recursive: true });
|
|
466
871
|
await cp(source, destination);
|
|
467
872
|
}
|
|
873
|
+
var manifestPath = (root) => join4(root, "manifest.json"), exists4 = async (path) => {
|
|
874
|
+
try {
|
|
875
|
+
await stat4(path);
|
|
876
|
+
return true;
|
|
877
|
+
} catch {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
}, sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
881
|
+
var init_project = __esm(() => {
|
|
882
|
+
init_package();
|
|
883
|
+
init_document_skill();
|
|
884
|
+
init_theme();
|
|
885
|
+
init_vite_build_plugin();
|
|
886
|
+
init_data_schema();
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// src/cli.ts
|
|
890
|
+
init_project();
|
|
891
|
+
import { Crust } from "@crustjs/core";
|
|
892
|
+
import { join as join10 } from "path";
|
|
893
|
+
import { stat as stat6 } from "fs/promises";
|
|
894
|
+
import { createInterface } from "readline/promises";
|
|
895
|
+
import { validate, SlopCategorySchema } from "@hitslop/schema";
|
|
468
896
|
|
|
469
897
|
// src/dev.ts
|
|
470
|
-
import {
|
|
471
|
-
import { join as join4, resolve as resolve2 } from "path";
|
|
898
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
472
899
|
import { createServer } from "vite";
|
|
473
900
|
|
|
474
901
|
// src/dev-bridge.ts
|
|
902
|
+
import { protocolVersion } from "@hitslop/schema/bridge";
|
|
475
903
|
var hostStyle = `<style data-hitslop-host>*{scrollbar-width:none!important}*::-webkit-scrollbar{width:0!important;height:0!important;display:none!important}</style>`;
|
|
476
904
|
var devHostJavaScript = `(() => {
|
|
905
|
+
const captureMode = new URL(window.location?.href ?? 'http://localhost/').searchParams.get('capture');
|
|
906
|
+
if (captureMode === 'icon') document.documentElement.dataset.slopRenderer = 'true';
|
|
907
|
+
if (captureMode === 'icon' || captureMode === 'export') {
|
|
908
|
+
window.addEventListener('slop:ready', () => {
|
|
909
|
+
setTimeout(async () => {
|
|
910
|
+
try {
|
|
911
|
+
if (captureMode === 'icon') document.documentElement.style.width = '512px';
|
|
912
|
+
await window.__hitslopCapture.begin('dev-preview', captureMode, {blockInteraction:false});
|
|
913
|
+
} catch (error) { console.error('Capture preview failed', error); }
|
|
914
|
+
}, 0);
|
|
915
|
+
}, {once:true});
|
|
916
|
+
}
|
|
477
917
|
const listeners = { json: new Set(), sqlite: new Set(), media: new Set() };
|
|
478
918
|
let jsonValue;
|
|
479
919
|
let jsonRevision = 0;
|
|
@@ -492,6 +932,8 @@ var devHostJavaScript = `(() => {
|
|
|
492
932
|
const drag = async () => undefined;
|
|
493
933
|
|
|
494
934
|
window.slop = Object.freeze({
|
|
935
|
+
info: async () => ({ protocolVersion: ${protocolVersion}, capabilities: ['host.info', 'json.open', 'json.read', 'json.write', 'window.resize', 'window.drag'] }),
|
|
936
|
+
flush: async () => undefined,
|
|
495
937
|
json: Object.freeze({
|
|
496
938
|
open: async (value) => {
|
|
497
939
|
if (!jsonOpened) { jsonValue = clone(value); jsonOpened = true; }
|
|
@@ -502,7 +944,7 @@ var devHostJavaScript = `(() => {
|
|
|
502
944
|
return { value: clone(jsonValue), revision: revision() };
|
|
503
945
|
},
|
|
504
946
|
write: async (value, expectedRevision) => {
|
|
505
|
-
if (expectedRevision && expectedRevision !== revision()) throw new Error('revision_conflict');
|
|
947
|
+
if (expectedRevision !== undefined && expectedRevision !== revision()) throw Object.assign(new Error('Document changed'), { code: 'revision_conflict' });
|
|
506
948
|
jsonValue = clone(value); jsonOpened = true; jsonRevision += 1;
|
|
507
949
|
const result = { revision: revision() };
|
|
508
950
|
emit('json', { source: 'app', revision: result.revision });
|
|
@@ -545,14 +987,8 @@ var injectHost = (html, options = {}) => {
|
|
|
545
987
|
};
|
|
546
988
|
|
|
547
989
|
// src/dev.ts
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
await stat4(path);
|
|
551
|
-
return true;
|
|
552
|
-
} catch {
|
|
553
|
-
return false;
|
|
554
|
-
}
|
|
555
|
-
};
|
|
990
|
+
init_project();
|
|
991
|
+
init_theme();
|
|
556
992
|
function mockHostPlugin(options = {}) {
|
|
557
993
|
return {
|
|
558
994
|
name: "hitslop-browser-preview",
|
|
@@ -564,8 +1000,8 @@ function mockHostPlugin(options = {}) {
|
|
|
564
1000
|
}
|
|
565
1001
|
async function runDev(root) {
|
|
566
1002
|
await loadManifest(root);
|
|
567
|
-
const themeHref = await
|
|
568
|
-
const server = await createServer({ root, plugins: [mockHostPlugin(themeHref ? { themeHref } : {})] });
|
|
1003
|
+
const themeHref = await readThemeCSS(root) !== undefined ? "/assets/theme.css" : undefined;
|
|
1004
|
+
const server = await createServer({ root, plugins: [themePreviewPlugin(root), mockHostPlugin(themeHref ? { themeHref } : {})] });
|
|
569
1005
|
await server.listen();
|
|
570
1006
|
const url = server.resolvedUrls?.local[0];
|
|
571
1007
|
if (!url)
|
|
@@ -574,42 +1010,46 @@ async function runDev(root) {
|
|
|
574
1010
|
if (process.platform === "darwin")
|
|
575
1011
|
Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore" });
|
|
576
1012
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
1013
|
+
function themePreviewPlugin(root) {
|
|
1014
|
+
root = resolve3(root);
|
|
1015
|
+
return {
|
|
1016
|
+
name: "hitslop:theme-preview",
|
|
1017
|
+
configureServer(server) {
|
|
1018
|
+
server.middlewares.use(async (request, response, next) => {
|
|
1019
|
+
if (request.url?.split("?")[0] !== "/assets/theme.css")
|
|
1020
|
+
return next();
|
|
1021
|
+
try {
|
|
1022
|
+
const css = await readThemeCSS(root);
|
|
1023
|
+
if (css === undefined)
|
|
1024
|
+
return next();
|
|
1025
|
+
response.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
1026
|
+
response.end(css);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
next(error);
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
},
|
|
1032
|
+
handleHotUpdate({ file, server }) {
|
|
1033
|
+
if (file === join5(root, "theme.ts"))
|
|
1034
|
+
server.ws.send({ type: "full-reload" });
|
|
594
1035
|
}
|
|
595
|
-
}
|
|
596
|
-
throw new Error("Install hitSlop or set HITSLOP_NATIVE_CLI to use native rendering.");
|
|
1036
|
+
};
|
|
597
1037
|
}
|
|
598
1038
|
|
|
599
1039
|
// src/publish.ts
|
|
600
|
-
|
|
601
|
-
import { cp as cp3, mkdir as mkdir5, rm as rm3 } from "fs/promises";
|
|
1040
|
+
init_project();
|
|
602
1041
|
import { canonicalPublishEnvelope } from "@hitslop/schema";
|
|
603
1042
|
|
|
604
1043
|
// src/identity.ts
|
|
1044
|
+
init_project();
|
|
605
1045
|
import { getPublicKeyAsync, keygenAsync, signAsync } from "@noble/ed25519";
|
|
606
1046
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
|
607
|
-
import { chmod, mkdir as mkdir3, readFile as
|
|
608
|
-
import { join as
|
|
609
|
-
import { homedir
|
|
1047
|
+
import { chmod, mkdir as mkdir3, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
|
|
1048
|
+
import { join as join6, resolve as resolve4 } from "path";
|
|
1049
|
+
import { homedir } from "os";
|
|
610
1050
|
var service = "app.hitslop.cli";
|
|
611
1051
|
var account = "publisher";
|
|
612
|
-
var fallback =
|
|
1052
|
+
var fallback = join6(homedir(), ".hitslop", "identity.json");
|
|
613
1053
|
var encode = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
614
1054
|
var decode = (value) => new Uint8Array(Buffer.from(value, "base64url"));
|
|
615
1055
|
async function keychainRead() {
|
|
@@ -625,26 +1065,24 @@ async function keychainWrite(value) {
|
|
|
625
1065
|
return result.exitCode === 0;
|
|
626
1066
|
}
|
|
627
1067
|
async function readStored() {
|
|
628
|
-
const value = await keychainRead() ?? await
|
|
1068
|
+
const value = await keychainRead() ?? await readFile6(fallback, "utf8").catch(() => null);
|
|
629
1069
|
return value ? JSON.parse(value) : null;
|
|
630
1070
|
}
|
|
631
1071
|
async function storeIdentity(identity) {
|
|
632
1072
|
const json = JSON.stringify(identity);
|
|
633
1073
|
if (!await keychainWrite(json)) {
|
|
634
|
-
await mkdir3(
|
|
635
|
-
await
|
|
1074
|
+
await mkdir3(join6(homedir(), ".hitslop"), { recursive: true });
|
|
1075
|
+
await writeFile4(fallback, json, { mode: 384 });
|
|
636
1076
|
await chmod(fallback, 384);
|
|
637
1077
|
}
|
|
638
1078
|
}
|
|
639
1079
|
async function validated(identity) {
|
|
640
|
-
if (!identity.displayName.trim() || identity.displayName.length > 80)
|
|
641
|
-
throw new Error("Publisher name must contain 1\u201380 characters.");
|
|
642
1080
|
const derivedPublic = await getPublicKeyAsync(decode(identity.privateKey));
|
|
643
1081
|
if (encode(derivedPublic) !== identity.publicKey)
|
|
644
1082
|
throw new Error("Identity public and private keys do not match.");
|
|
645
1083
|
if (sha256(derivedPublic).slice(0, 32) !== identity.keyId)
|
|
646
1084
|
throw new Error("Identity key ID does not match its public key.");
|
|
647
|
-
return {
|
|
1085
|
+
return { privateKey: identity.privateKey, publicKey: identity.publicKey, keyId: identity.keyId };
|
|
648
1086
|
}
|
|
649
1087
|
async function getIdentity() {
|
|
650
1088
|
const stored = await readStored();
|
|
@@ -652,16 +1090,10 @@ async function getIdentity() {
|
|
|
652
1090
|
return validated(stored);
|
|
653
1091
|
const pair = await keygenAsync();
|
|
654
1092
|
const publicKey = pair.publicKey ?? await getPublicKeyAsync(pair.secretKey);
|
|
655
|
-
const identity = await validated({ privateKey: encode(pair.secretKey), publicKey: encode(publicKey), keyId: sha256(publicKey).slice(0, 32)
|
|
1093
|
+
const identity = await validated({ privateKey: encode(pair.secretKey), publicKey: encode(publicKey), keyId: sha256(publicKey).slice(0, 32) });
|
|
656
1094
|
await storeIdentity(identity);
|
|
657
1095
|
return identity;
|
|
658
1096
|
}
|
|
659
|
-
async function setIdentityName(displayName) {
|
|
660
|
-
const identity = await getIdentity();
|
|
661
|
-
const next = await validated({ ...identity, displayName });
|
|
662
|
-
await storeIdentity(next);
|
|
663
|
-
return next;
|
|
664
|
-
}
|
|
665
1097
|
async function exportIdentity(path, passphrase) {
|
|
666
1098
|
if (passphrase.length < 10)
|
|
667
1099
|
throw new Error("Identity export passphrases must contain at least 10 characters.");
|
|
@@ -672,15 +1104,15 @@ async function exportIdentity(path, passphrase) {
|
|
|
672
1104
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
673
1105
|
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(identity), "utf8"), cipher.final()]);
|
|
674
1106
|
const payload = { version: 1, kdf: "scrypt", salt: encode(salt), cipher: "aes-256-gcm", iv: encode(iv), tag: encode(cipher.getAuthTag()), ciphertext: encode(ciphertext) };
|
|
675
|
-
const output =
|
|
676
|
-
await
|
|
1107
|
+
const output = resolve4(path);
|
|
1108
|
+
await writeFile4(output, `${JSON.stringify(payload, null, 2)}
|
|
677
1109
|
`, { mode: 384 });
|
|
678
1110
|
await chmod(output, 384);
|
|
679
1111
|
}
|
|
680
1112
|
async function importIdentity(path, passphrase, force = false) {
|
|
681
1113
|
if (await readStored() && !force)
|
|
682
1114
|
throw new Error("A publisher identity already exists. Pass --force to replace it.");
|
|
683
|
-
const payload = JSON.parse(await
|
|
1115
|
+
const payload = JSON.parse(await readFile6(resolve4(path), "utf8"));
|
|
684
1116
|
if (payload.version !== 1 || payload.kdf !== "scrypt" || payload.cipher !== "aes-256-gcm")
|
|
685
1117
|
throw new Error("Unsupported identity export format.");
|
|
686
1118
|
const key = scryptSync(passphrase, decode(payload.salt), 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
|
|
@@ -698,390 +1130,161 @@ async function importIdentity(path, passphrase, force = false) {
|
|
|
698
1130
|
}
|
|
699
1131
|
var sign = async (message, identity) => encode(await signAsync(message, decode(identity.privateKey)));
|
|
700
1132
|
|
|
701
|
-
// src/
|
|
702
|
-
import {
|
|
703
|
-
import {
|
|
704
|
-
import { homedir as homedir2 } from "os";
|
|
705
|
-
import { join as join7, resolve as resolve4 } from "path";
|
|
706
|
-
|
|
707
|
-
// src/runtime-package.ts
|
|
708
|
-
import { Database } from "bun:sqlite";
|
|
709
|
-
import { lstat as lstat3, readFile as readFile6, readdir as readdir3, stat as stat5 } from "fs/promises";
|
|
710
|
-
import { basename as basename2, join as join6, relative as relative2, sep as sep2 } from "path";
|
|
711
|
-
import { unzipSync } from "fflate";
|
|
712
|
-
import { decode as decodePng3 } from "fast-png";
|
|
713
|
-
import * as z2 from "zod";
|
|
714
|
-
|
|
715
|
-
// src/static-preview.ts
|
|
716
|
-
import { readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
717
|
-
import { decode as decodePng2, encode as encodePng } from "fast-png";
|
|
718
|
-
var MAX_PREVIEW_BYTES = 5 * 1024 * 1024;
|
|
719
|
-
var ICON_SIZE = 512;
|
|
720
|
-
function validateStaticPng(bytes, label) {
|
|
721
|
-
if (bytes.byteLength === 0)
|
|
722
|
-
throw new Error(`${label} is empty.`);
|
|
723
|
-
if (bytes.byteLength > MAX_PREVIEW_BYTES)
|
|
724
|
-
throw new Error(`${label} cannot exceed 5 MiB.`);
|
|
725
|
-
try {
|
|
726
|
-
return decodePng2(bytes, { checkCrc: true });
|
|
727
|
-
} catch {
|
|
728
|
-
throw new Error(`${label} must be a valid PNG image.`);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
function validateIconPng(bytes, label = "The template icon") {
|
|
732
|
-
const image = validateStaticPng(bytes, label);
|
|
733
|
-
if (image.width !== ICON_SIZE || image.height !== ICON_SIZE)
|
|
734
|
-
throw new Error(`${label} must be exactly ${ICON_SIZE}x${ICON_SIZE} pixels.`);
|
|
735
|
-
return image;
|
|
736
|
-
}
|
|
737
|
-
function iconFromPng(bytes, size = ICON_SIZE) {
|
|
738
|
-
const image = validateStaticPng(bytes, "The template preview");
|
|
739
|
-
const scale = size / Math.max(image.width, image.height);
|
|
740
|
-
const width = Math.max(1, Math.round(image.width * scale));
|
|
741
|
-
const height = Math.max(1, Math.round(image.height * scale));
|
|
742
|
-
const output = image.depth === 16 ? new Uint16Array(size * size * 4) : new Uint8Array(size * size * 4);
|
|
743
|
-
const alpha = image.depth === 16 ? 65535 : 255;
|
|
744
|
-
const offsetX = Math.floor((size - width) / 2);
|
|
745
|
-
const offsetY = Math.floor((size - height) / 2);
|
|
746
|
-
for (let y = 0;y < height; y += 1) {
|
|
747
|
-
const sourceY = Math.min(image.height - 1, Math.floor((y + 0.5) * image.height / height));
|
|
748
|
-
for (let x = 0;x < width; x += 1) {
|
|
749
|
-
const sourceX = Math.min(image.width - 1, Math.floor((x + 0.5) * image.width / width));
|
|
750
|
-
const sourceOffset = (sourceY * image.width + sourceX) * image.channels;
|
|
751
|
-
const destinationOffset = ((offsetY + y) * size + offsetX + x) * 4;
|
|
752
|
-
if (image.channels === 1 || image.channels === 2) {
|
|
753
|
-
const gray = image.data[sourceOffset];
|
|
754
|
-
output[destinationOffset] = gray;
|
|
755
|
-
output[destinationOffset + 1] = gray;
|
|
756
|
-
output[destinationOffset + 2] = gray;
|
|
757
|
-
output[destinationOffset + 3] = image.channels === 2 ? image.data[sourceOffset + 1] : alpha;
|
|
758
|
-
} else {
|
|
759
|
-
output[destinationOffset] = image.data[sourceOffset];
|
|
760
|
-
output[destinationOffset + 1] = image.data[sourceOffset + 1];
|
|
761
|
-
output[destinationOffset + 2] = image.data[sourceOffset + 2];
|
|
762
|
-
output[destinationOffset + 3] = image.channels === 4 ? image.data[sourceOffset + 3] : alpha;
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
return encodePng({ width: size, height: size, data: output, depth: image.depth, channels: 4 });
|
|
767
|
-
}
|
|
768
|
-
async function writeDefaultIcon(previewPath, iconPath) {
|
|
769
|
-
await writeFile4(iconPath, iconFromPng(await readFile5(previewPath)));
|
|
770
|
-
}
|
|
1133
|
+
// src/template-capture.ts
|
|
1134
|
+
import { cp as cp2, mkdir as mkdir4, readFile as readFile7, rm as rm2 } from "fs/promises";
|
|
1135
|
+
import { join as join8, resolve as resolve6 } from "path";
|
|
771
1136
|
|
|
772
|
-
// src/
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
await
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
const forbidden = new Set(["package.json", "bun.lock", "bun.lockb", "node_modules", "source", "src", "build", "document.json", ".build", ".hitslop", "style.css"]);
|
|
792
|
-
for (const path of await walk2(root)) {
|
|
793
|
-
if (forbidden.has(basename2(path).toLowerCase()))
|
|
794
|
-
throw new Error(`Runtime packages cannot contain ${relative2(root, path)}.`);
|
|
795
|
-
}
|
|
796
|
-
if (options.template && await exists5(join6(root, "stores")))
|
|
797
|
-
throw new Error("Template packages cannot contain stores.");
|
|
798
|
-
if (options.template && await exists5(join6(root, "Icon\r")))
|
|
799
|
-
throw new Error("Template packages cannot contain Finder metadata Icon\\r.");
|
|
800
|
-
const schema = await readDataSchema(root);
|
|
801
|
-
const contract = await readThemeContract(root);
|
|
802
|
-
const appHTML = await readFile6(join6(root, "app.html"), "utf8");
|
|
803
|
-
validateThemeReferences(appHTML, contract, "app.html");
|
|
804
|
-
await validateStores(root, schema, contract);
|
|
805
|
-
await validateQuickLook(root, options.requirePreview ?? false);
|
|
806
|
-
await validateDocumentSkill(root);
|
|
807
|
-
return manifest;
|
|
808
|
-
}
|
|
809
|
-
async function readDataSchema(root) {
|
|
810
|
-
const current = join6(root, "data.schema.json");
|
|
811
|
-
if (!await exists5(current))
|
|
812
|
-
return;
|
|
813
|
-
await requireUTF8File(current, "data.schema.json");
|
|
814
|
-
try {
|
|
815
|
-
const value = JSON.parse(await readFile6(current, "utf8"));
|
|
816
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
817
|
-
throw new Error("schema root must be an object");
|
|
818
|
-
z2.fromJSONSchema(value);
|
|
819
|
-
return value;
|
|
820
|
-
} catch (error) {
|
|
821
|
-
throw new Error(`data.schema.json must contain valid JSON Schema: ${error instanceof Error ? error.message : String(error)}`);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
async function validateStores(root, schema, contract) {
|
|
825
|
-
const stores = join6(root, "stores");
|
|
826
|
-
if (!await exists5(stores))
|
|
827
|
-
return;
|
|
828
|
-
const info = await lstat3(stores);
|
|
829
|
-
if (!info.isDirectory() || info.isSymbolicLink())
|
|
830
|
-
throw new Error("stores must be a directory.");
|
|
831
|
-
const allowed = new Set(["data.json", "data.sqlite", "data.sqlite-wal", "data.sqlite-shm", "media", "theme.css"]);
|
|
832
|
-
for (const entry of await readdir3(stores, { withFileTypes: true })) {
|
|
833
|
-
if (!allowed.has(entry.name))
|
|
834
|
-
throw new Error(`Unexpected store entry stores/${entry.name}.`);
|
|
835
|
-
if (entry.name === "media" ? !entry.isDirectory() : !entry.isFile())
|
|
836
|
-
throw new Error(`Invalid store entry stores/${entry.name}.`);
|
|
837
|
-
}
|
|
838
|
-
const json = join6(stores, "data.json");
|
|
839
|
-
if (await exists5(json)) {
|
|
840
|
-
let value;
|
|
1137
|
+
// src/native.ts
|
|
1138
|
+
import { homedir as homedir2 } from "os";
|
|
1139
|
+
import { join as join7, resolve as resolve5 } from "path";
|
|
1140
|
+
async function runNative(arguments_) {
|
|
1141
|
+
const override = process.env.HITSLOP_NATIVE_CLI;
|
|
1142
|
+
const arch = process.arch === "arm64" ? "arm64-apple-macosx" : "x86_64-apple-macosx";
|
|
1143
|
+
const build = resolve5(import.meta.dir, "../../../apps/apple/Packages/HitSlopApple/.build", arch);
|
|
1144
|
+
const candidates = override !== undefined ? [override] : [
|
|
1145
|
+
"/Applications/hitSlop.app/Contents/Helpers/hitslop-native",
|
|
1146
|
+
join7(homedir2(), "Applications/hitSlop.app/Contents/Helpers/hitslop-native"),
|
|
1147
|
+
join7(build, "debug/hitslop-native"),
|
|
1148
|
+
join7(build, "release/hitslop-native"),
|
|
1149
|
+
"hitslop-native"
|
|
1150
|
+
];
|
|
1151
|
+
await runNativeCandidates(candidates, arguments_, override !== undefined);
|
|
1152
|
+
}
|
|
1153
|
+
async function runNativeCandidates(candidates, arguments_, explicit = false) {
|
|
1154
|
+
for (const executable of candidates) {
|
|
1155
|
+
let child;
|
|
841
1156
|
try {
|
|
842
|
-
|
|
1157
|
+
child = Bun.spawn([executable, ...arguments_], { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
843
1158
|
} catch (error) {
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
let validator;
|
|
848
|
-
try {
|
|
849
|
-
validator = z2.fromJSONSchema(schema);
|
|
850
|
-
} catch (error) {
|
|
851
|
-
throw new Error(`The data schema is not supported: ${error instanceof Error ? error.message : String(error)}`);
|
|
852
|
-
}
|
|
853
|
-
const result = validator.safeParse(value);
|
|
854
|
-
if (!result.success)
|
|
855
|
-
throw new Error(`stores/data.json does not match the data schema: ${z2.prettifyError(result.error)}`);
|
|
1159
|
+
if (!explicit && error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
1160
|
+
continue;
|
|
1161
|
+
throw new Error(`Could not launch native helper ${executable}`, { cause: error });
|
|
856
1162
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if (await exists5(join6(stores, sidecar)) && !await exists5(sqlite))
|
|
861
|
-
throw new Error(`stores/${sidecar} requires stores/data.sqlite.`);
|
|
862
|
-
}
|
|
863
|
-
if (await exists5(sqlite))
|
|
864
|
-
validateSQLite(sqlite);
|
|
865
|
-
const media = join6(stores, "media");
|
|
866
|
-
if (await exists5(media))
|
|
867
|
-
await validateMedia(media);
|
|
868
|
-
await validateThemeOverride(root, contract);
|
|
869
|
-
}
|
|
870
|
-
function validateSQLite(path) {
|
|
871
|
-
let database;
|
|
872
|
-
try {
|
|
873
|
-
database = new Database(path, { readonly: true, strict: true });
|
|
874
|
-
const rows = database.query("PRAGMA quick_check").all();
|
|
875
|
-
if (rows.length !== 1 || Object.values(rows[0] ?? {})[0] !== "ok")
|
|
876
|
-
throw new Error("PRAGMA quick_check did not return ok");
|
|
877
|
-
} catch (error) {
|
|
878
|
-
throw new Error(`stores/data.sqlite failed its integrity check: ${error instanceof Error ? error.message : String(error)}`);
|
|
879
|
-
} finally {
|
|
880
|
-
database?.close();
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
async function validateMedia(directory) {
|
|
884
|
-
const entries = await readdir3(directory, { withFileTypes: true });
|
|
885
|
-
for (const entry of entries) {
|
|
886
|
-
if (!entry.isFile() || entry.isSymbolicLink())
|
|
887
|
-
throw new Error(`Named media must be regular files: stores/media/${entry.name}.`);
|
|
888
|
-
if (!/^[a-z][a-z0-9-]{0,63}$/.test(entry.name))
|
|
889
|
-
throw new Error(`Invalid named media key: ${entry.name}.`);
|
|
890
|
-
const bytes = await readFile6(join6(directory, entry.name));
|
|
891
|
-
if (!isImage(bytes) && !isBoundedZip(bytes))
|
|
892
|
-
throw new Error(`Unsupported named media content: ${entry.name}.`);
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
function isImage(bytes) {
|
|
896
|
-
if (bytes.byteLength > 25 * MiB)
|
|
897
|
-
return false;
|
|
898
|
-
if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
|
|
899
|
-
try {
|
|
900
|
-
decodePng3(bytes, { checkCrc: true });
|
|
901
|
-
return true;
|
|
902
|
-
} catch {
|
|
903
|
-
return false;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
if (bytes[0] === 255 && bytes[1] === 216 && bytes.at(-2) === 255 && bytes.at(-1) === 217)
|
|
907
|
-
return true;
|
|
908
|
-
if (String.fromCharCode(...bytes.subarray(0, 6)).match(/^GIF8[79]a$/))
|
|
909
|
-
return true;
|
|
910
|
-
return String.fromCharCode(...bytes.subarray(0, 4)) === "RIFF" && String.fromCharCode(...bytes.subarray(8, 12)) === "WEBP";
|
|
911
|
-
}
|
|
912
|
-
function isBoundedZip(bytes) {
|
|
913
|
-
if (bytes.byteLength > 10 * MiB || bytes[0] !== 80 || bytes[1] !== 75)
|
|
914
|
-
return false;
|
|
915
|
-
try {
|
|
916
|
-
const files = unzipSync(bytes);
|
|
917
|
-
const names = Object.keys(files);
|
|
918
|
-
if (!names.length || names.length > 256)
|
|
919
|
-
return false;
|
|
920
|
-
let total = 0;
|
|
921
|
-
for (const [name, value] of Object.entries(files)) {
|
|
922
|
-
if (!safeArchivePath(name) || value.byteLength > 25 * MiB)
|
|
923
|
-
return false;
|
|
924
|
-
total += value.byteLength;
|
|
925
|
-
if (total > 50 * MiB)
|
|
926
|
-
return false;
|
|
927
|
-
}
|
|
928
|
-
return true;
|
|
929
|
-
} catch {
|
|
930
|
-
return false;
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
var safeArchivePath = (path) => Boolean(path) && !path.startsWith("/") && !path.includes("\\") && !path.includes("\x00") && path.replace(/\/$/, "").split("/").every((part) => part && part !== "." && part !== "..");
|
|
934
|
-
async function validateQuickLook(root, required) {
|
|
935
|
-
const directory = join6(root, "QuickLook");
|
|
936
|
-
if (!await exists5(directory)) {
|
|
937
|
-
if (required)
|
|
938
|
-
throw new Error("Template packages must contain QuickLook/Preview.png and QuickLook/Icon.png.");
|
|
1163
|
+
const status = await child.exited;
|
|
1164
|
+
if (status !== 0)
|
|
1165
|
+
throw new Error(`Native helper ${executable} failed with status ${status}`);
|
|
939
1166
|
return;
|
|
940
1167
|
}
|
|
941
|
-
|
|
942
|
-
if (!info.isDirectory() || info.isSymbolicLink())
|
|
943
|
-
throw new Error("QuickLook must be a directory.");
|
|
944
|
-
const allowed = new Set(["Preview.png", "Icon.png"]);
|
|
945
|
-
for (const entry of await readdir3(directory))
|
|
946
|
-
if (!allowed.has(entry))
|
|
947
|
-
throw new Error(`Unexpected QuickLook entry QuickLook/${entry}.`);
|
|
948
|
-
const preview = join6(directory, "Preview.png");
|
|
949
|
-
const icon = join6(directory, "Icon.png");
|
|
950
|
-
if (required && (!await exists5(preview) || !await exists5(icon)))
|
|
951
|
-
throw new Error("Template packages must contain QuickLook/Preview.png and QuickLook/Icon.png.");
|
|
952
|
-
if (await exists5(preview))
|
|
953
|
-
validateStaticPng(await readFile6(preview), "QuickLook/Preview.png");
|
|
954
|
-
if (await exists5(icon))
|
|
955
|
-
validateIconPng(await readFile6(icon));
|
|
1168
|
+
throw new Error("Install hitSlop or set HITSLOP_NATIVE_CLI to use native rendering.");
|
|
956
1169
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1170
|
+
|
|
1171
|
+
// src/template-capture.ts
|
|
1172
|
+
init_static_preview();
|
|
1173
|
+
init_runtime_package();
|
|
1174
|
+
async function prepareTemplateCaptures(directory, options = {}) {
|
|
1175
|
+
const quickLook = join8(directory, "QuickLook");
|
|
1176
|
+
await mkdir4(quickLook, { recursive: true });
|
|
1177
|
+
const preview = join8(quickLook, "Preview.png");
|
|
1178
|
+
const icon = join8(quickLook, "Icon.png");
|
|
963
1179
|
try {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1180
|
+
if (options.preview)
|
|
1181
|
+
await cp2(resolve6(options.preview), preview);
|
|
1182
|
+
else
|
|
1183
|
+
await (options.capturePreview ?? ((path, output) => runNative(["screenshot", path, "--output", output])))(directory, preview);
|
|
1184
|
+
validateStaticPng(await readFile7(preview), "The template preview");
|
|
1185
|
+
if (options.icon)
|
|
1186
|
+
await cp2(resolve6(options.icon), icon);
|
|
1187
|
+
else {
|
|
1188
|
+
await rm2(icon, { force: true });
|
|
1189
|
+
const captured = options.captureIcon ? await options.captureIcon(directory, icon) : await runNative(["screenshot", directory, "--target", "icon", "--if-present", "--output", icon]).then(() => Bun.file(icon).exists());
|
|
1190
|
+
if (!captured || !await Bun.file(icon).exists())
|
|
1191
|
+
await writeDefaultIcon(preview, icon);
|
|
1192
|
+
}
|
|
1193
|
+
validateIconPng(await readFile7(icon));
|
|
1194
|
+
} finally {
|
|
1195
|
+
await rm2(join8(directory, "stores"), { recursive: true, force: true });
|
|
967
1196
|
}
|
|
1197
|
+
await validateRuntimePackage(directory, { template: true, requirePreview: true });
|
|
968
1198
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1199
|
+
|
|
1200
|
+
// src/publish.ts
|
|
1201
|
+
var blobPart = (bytes) => Uint8Array.from(bytes);
|
|
1202
|
+
async function publishSlop(root, flags) {
|
|
1203
|
+
const built = await buildSlop(root);
|
|
1204
|
+
await prepareTemplateCaptures(built.directory, flags);
|
|
1205
|
+
const packed = await packSlop(built.directory);
|
|
1206
|
+
const identity = await getIdentity();
|
|
1207
|
+
const envelope = {
|
|
1208
|
+
format: "hitslop-publish/3",
|
|
1209
|
+
requestId: crypto.randomUUID(),
|
|
1210
|
+
publisherKeyId: identity.keyId,
|
|
1211
|
+
publicKey: identity.publicKey,
|
|
1212
|
+
artifactSha256: packed.sha256,
|
|
1213
|
+
artifactBytes: packed.bytes.byteLength,
|
|
1214
|
+
timestamp: Date.now()
|
|
1215
|
+
};
|
|
1216
|
+
const signature = await sign(canonicalPublishEnvelope(envelope), identity);
|
|
1217
|
+
const form = new FormData;
|
|
1218
|
+
form.set("envelope", JSON.stringify(envelope));
|
|
1219
|
+
form.set("signature", signature);
|
|
1220
|
+
form.set("artifact", new Blob([blobPart(packed.bytes)], { type: "application/zip" }), `${built.manifest.slug}.slop.zip`);
|
|
1221
|
+
const endpoint = flags.registry ?? process.env.HITSLOP_REGISTRY_URL ?? "https://api.hitslop.com/api/publish";
|
|
1222
|
+
const response = await fetch(endpoint, { method: "POST", body: form });
|
|
1223
|
+
const body = await response.text();
|
|
1224
|
+
if (!response.ok)
|
|
1225
|
+
throw new Error(`Publish failed (${response.status}): ${body}`);
|
|
1226
|
+
return body;
|
|
983
1227
|
}
|
|
984
1228
|
|
|
985
1229
|
// src/install.ts
|
|
986
|
-
|
|
1230
|
+
init_project();
|
|
1231
|
+
init_runtime_package();
|
|
1232
|
+
import { chmod as chmod2, cp as cp3, lstat as lstat4, mkdir as mkdir5, readdir as readdir4, rename as rename2, rm as rm3, stat as stat5 } from "fs/promises";
|
|
1233
|
+
import { randomUUID } from "crypto";
|
|
1234
|
+
import { homedir as homedir3 } from "os";
|
|
1235
|
+
import { join as join9, resolve as resolve7 } from "path";
|
|
1236
|
+
var exists5 = async (path) => stat5(path).then(() => true).catch(() => false);
|
|
987
1237
|
async function installTemplate(input, options = {}) {
|
|
988
|
-
const source =
|
|
1238
|
+
const source = resolve7(input);
|
|
989
1239
|
const runtime = await isRuntimePackage(source) ? source : (await buildSlop(source)).directory;
|
|
990
1240
|
const manifest = await loadManifest(runtime);
|
|
991
1241
|
await validateTemplatePackage(runtime);
|
|
992
|
-
const templatesRoot =
|
|
993
|
-
const target =
|
|
994
|
-
const replaced = await
|
|
1242
|
+
const templatesRoot = resolve7(options.templatesRoot ?? process.env.HITSLOP_TEMPLATES_ROOT ?? join9(homedir3(), ".hitslop", "templates"));
|
|
1243
|
+
const target = join9(templatesRoot, `${manifest.slug}.slop`);
|
|
1244
|
+
const replaced = await exists5(target);
|
|
995
1245
|
if (replaced && !options.force) {
|
|
996
1246
|
if (!options.confirmOverwrite)
|
|
997
1247
|
throw new Error(`Template ${manifest.slug} is already installed. Pass --force to replace it.`);
|
|
998
1248
|
if (!await options.confirmOverwrite(target))
|
|
999
1249
|
throw new Error("Install cancelled.");
|
|
1000
1250
|
}
|
|
1001
|
-
await
|
|
1002
|
-
const
|
|
1003
|
-
const
|
|
1251
|
+
await mkdir5(templatesRoot, { recursive: true });
|
|
1252
|
+
const stagingRoot = join9(templatesRoot, `.${manifest.slug}.${randomUUID()}.installing`);
|
|
1253
|
+
const staging = join9(stagingRoot, `${manifest.slug}.slop`);
|
|
1254
|
+
const backup = join9(templatesRoot, `.${manifest.slug}.${randomUUID()}.backup.slop`);
|
|
1004
1255
|
try {
|
|
1005
|
-
await
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
await mkdir4(quickLook, { recursive: true });
|
|
1009
|
-
if (options.preview)
|
|
1010
|
-
await cp2(resolve4(options.preview), preview);
|
|
1011
|
-
else {
|
|
1012
|
-
const capture = options.capturePreview ?? ((packageDirectory, output) => runNative(["screenshot", packageDirectory, "--output", output]));
|
|
1013
|
-
await capture(staging, preview);
|
|
1014
|
-
}
|
|
1015
|
-
await validatePreview(preview);
|
|
1016
|
-
const icon = join7(quickLook, "Icon.png");
|
|
1017
|
-
if (options.icon)
|
|
1018
|
-
await cp2(resolve4(options.icon), icon);
|
|
1019
|
-
else {
|
|
1020
|
-
await rm2(icon, { force: true });
|
|
1021
|
-
const captured = options.captureIcon ? await options.captureIcon(staging, icon) : await captureOptionalIcon(staging, icon);
|
|
1022
|
-
if (!captured || !await exists6(icon))
|
|
1023
|
-
await writeDefaultIcon(preview, icon);
|
|
1024
|
-
}
|
|
1025
|
-
validateIconPng(await readFile7(icon));
|
|
1026
|
-
await rm2(join7(staging, "stores"), { recursive: true, force: true });
|
|
1027
|
-
await validateQuickLook2(staging, true);
|
|
1256
|
+
await mkdir5(stagingRoot);
|
|
1257
|
+
await cp3(runtime, staging, { recursive: true, errorOnExist: true });
|
|
1258
|
+
await prepareTemplateCaptures(staging, options);
|
|
1028
1259
|
if (replaced) {
|
|
1029
1260
|
await makePackageWritable(target);
|
|
1030
|
-
await
|
|
1261
|
+
await rename2(target, backup);
|
|
1031
1262
|
}
|
|
1032
1263
|
try {
|
|
1033
|
-
await
|
|
1264
|
+
await rename2(staging, target);
|
|
1034
1265
|
} catch (error) {
|
|
1035
|
-
if (replaced && await
|
|
1036
|
-
await
|
|
1266
|
+
if (replaced && await exists5(backup))
|
|
1267
|
+
await rename2(backup, target);
|
|
1037
1268
|
throw error;
|
|
1038
1269
|
}
|
|
1039
1270
|
await makePackageImmutable(target);
|
|
1040
|
-
await
|
|
1271
|
+
await rm3(backup, { recursive: true, force: true });
|
|
1041
1272
|
return { directory: target, manifest, replaced };
|
|
1042
1273
|
} finally {
|
|
1043
|
-
await
|
|
1044
|
-
if (await
|
|
1045
|
-
await
|
|
1274
|
+
await rm3(stagingRoot, { recursive: true, force: true });
|
|
1275
|
+
if (await exists5(backup) && !await exists5(target)) {
|
|
1276
|
+
await rename2(backup, target);
|
|
1046
1277
|
await makePackageImmutable(target).catch(() => {});
|
|
1047
1278
|
} else
|
|
1048
|
-
await
|
|
1279
|
+
await rm3(backup, { recursive: true, force: true });
|
|
1049
1280
|
}
|
|
1050
1281
|
}
|
|
1051
|
-
async function captureOptionalIcon(packageDirectory, output) {
|
|
1052
|
-
await runNative(["screenshot", packageDirectory, "--target", "icon", "--if-present", "--output", output]);
|
|
1053
|
-
return await exists6(output);
|
|
1054
|
-
}
|
|
1055
|
-
async function validatePreview(path) {
|
|
1056
|
-
validateStaticPng(await readFile7(path), "The template preview");
|
|
1057
|
-
}
|
|
1058
1282
|
async function isRuntimePackage(path) {
|
|
1059
|
-
return await
|
|
1283
|
+
return await exists5(join9(path, "manifest.json")) && await exists5(join9(path, "app.html"));
|
|
1060
1284
|
}
|
|
1061
1285
|
async function validateTemplatePackage(path, options = {}) {
|
|
1062
1286
|
await validateRuntimePackage(path, { template: true, requirePreview: options.requirePreview ?? false });
|
|
1063
1287
|
}
|
|
1064
|
-
async function validateQuickLook2(path, requirePreview) {
|
|
1065
|
-
const directory = join7(path, "QuickLook");
|
|
1066
|
-
if (!await exists6(directory)) {
|
|
1067
|
-
if (requirePreview)
|
|
1068
|
-
throw new Error("Template packages must contain QuickLook/Preview.png and QuickLook/Icon.png.");
|
|
1069
|
-
return;
|
|
1070
|
-
}
|
|
1071
|
-
const allowed = new Set(["Preview.png", "Icon.png"]);
|
|
1072
|
-
for (const entry of await readdir4(directory))
|
|
1073
|
-
if (!allowed.has(entry))
|
|
1074
|
-
throw new Error(`Template packages cannot contain QuickLook/${entry}.`);
|
|
1075
|
-
const preview = join7(directory, "Preview.png");
|
|
1076
|
-
const icon = join7(directory, "Icon.png");
|
|
1077
|
-
if (!await exists6(preview) || !await exists6(icon)) {
|
|
1078
|
-
if (requirePreview)
|
|
1079
|
-
throw new Error("Template packages must contain QuickLook/Preview.png and QuickLook/Icon.png.");
|
|
1080
|
-
return;
|
|
1081
|
-
}
|
|
1082
|
-
await validatePreview(preview);
|
|
1083
|
-
validateIconPng(await readFile7(icon));
|
|
1084
|
-
}
|
|
1085
1288
|
async function makePackageWritable(root) {
|
|
1086
1289
|
const walk3 = async (path) => {
|
|
1087
1290
|
const info = await lstat4(path);
|
|
@@ -1091,7 +1294,7 @@ async function makePackageWritable(root) {
|
|
|
1091
1294
|
await chmod2(path, mode | (info.isDirectory() ? 448 : 384));
|
|
1092
1295
|
if (info.isDirectory())
|
|
1093
1296
|
for (const entry of await readdir4(path))
|
|
1094
|
-
await walk3(
|
|
1297
|
+
await walk3(join9(path, entry));
|
|
1095
1298
|
};
|
|
1096
1299
|
await walk3(root);
|
|
1097
1300
|
}
|
|
@@ -1102,62 +1305,17 @@ async function makePackageImmutable(root) {
|
|
|
1102
1305
|
throw new Error(`Runtime packages cannot contain symlinks: ${path}`);
|
|
1103
1306
|
if (info.isDirectory())
|
|
1104
1307
|
for (const entry of await readdir4(path))
|
|
1105
|
-
await walk3(
|
|
1308
|
+
await walk3(join9(path, entry));
|
|
1106
1309
|
await chmod2(path, info.mode & 511 & ~146);
|
|
1107
1310
|
};
|
|
1108
1311
|
await walk3(root);
|
|
1109
1312
|
}
|
|
1110
1313
|
|
|
1111
|
-
// src/
|
|
1112
|
-
|
|
1113
|
-
async function publishSlop(root, flags) {
|
|
1114
|
-
const built = await buildSlop(root);
|
|
1115
|
-
const quickLook = join8(built.directory, "QuickLook");
|
|
1116
|
-
const preview = join8(quickLook, "Preview.png");
|
|
1117
|
-
await mkdir5(quickLook, { recursive: true });
|
|
1118
|
-
if (flags.preview)
|
|
1119
|
-
await cp3(resolve5(flags.preview), preview);
|
|
1120
|
-
else
|
|
1121
|
-
await runNative(["screenshot", built.directory, "--output", preview]);
|
|
1122
|
-
validateStaticPng(new Uint8Array(await Bun.file(preview).arrayBuffer()), "The template preview");
|
|
1123
|
-
const icon = join8(quickLook, "Icon.png");
|
|
1124
|
-
if (flags.icon)
|
|
1125
|
-
await cp3(resolve5(flags.icon), icon);
|
|
1126
|
-
else {
|
|
1127
|
-
await rm3(icon, { force: true });
|
|
1128
|
-
await runNative(["screenshot", built.directory, "--target", "icon", "--if-present", "--output", icon]);
|
|
1129
|
-
if (!await Bun.file(icon).exists())
|
|
1130
|
-
await writeDefaultIcon(preview, icon);
|
|
1131
|
-
}
|
|
1132
|
-
validateIconPng(new Uint8Array(await Bun.file(icon).arrayBuffer()));
|
|
1133
|
-
await rm3(join8(built.directory, "stores"), { recursive: true, force: true });
|
|
1134
|
-
await validateTemplatePackage(built.directory, { requirePreview: true });
|
|
1135
|
-
const packed = await packSlop(built.directory);
|
|
1136
|
-
const identity = await getIdentity();
|
|
1137
|
-
const envelope = {
|
|
1138
|
-
format: "hitslop-publish/2",
|
|
1139
|
-
requestId: crypto.randomUUID(),
|
|
1140
|
-
publisherKeyId: identity.keyId,
|
|
1141
|
-
publicKey: identity.publicKey,
|
|
1142
|
-
displayName: identity.displayName,
|
|
1143
|
-
artifactSha256: packed.sha256,
|
|
1144
|
-
artifactBytes: packed.bytes.byteLength,
|
|
1145
|
-
timestamp: Date.now()
|
|
1146
|
-
};
|
|
1147
|
-
const signature = await sign(canonicalPublishEnvelope(envelope), identity);
|
|
1148
|
-
const form = new FormData;
|
|
1149
|
-
form.set("envelope", JSON.stringify(envelope));
|
|
1150
|
-
form.set("signature", signature);
|
|
1151
|
-
form.set("artifact", new Blob([blobPart(packed.bytes)], { type: "application/zip" }), `${built.manifest.slug}.slop.zip`);
|
|
1152
|
-
const endpoint = flags.registry ?? process.env.HITSLOP_REGISTRY_URL ?? "https://api.hitslop.com/api/publish";
|
|
1153
|
-
const response = await fetch(endpoint, { method: "POST", body: form });
|
|
1154
|
-
const body = await response.text();
|
|
1155
|
-
if (!response.ok)
|
|
1156
|
-
throw new Error(`Publish failed (${response.status}): ${body}`);
|
|
1157
|
-
return body;
|
|
1158
|
-
}
|
|
1314
|
+
// src/cli.ts
|
|
1315
|
+
init_runtime_package();
|
|
1159
1316
|
|
|
1160
1317
|
// src/render.ts
|
|
1318
|
+
init_runtime_package();
|
|
1161
1319
|
async function exportDocument(path, options, native = runNative) {
|
|
1162
1320
|
await validateRuntimePackage(path);
|
|
1163
1321
|
await native(["export", path, "--format", options.format, "--output", options.output]);
|
|
@@ -1177,11 +1335,16 @@ async function screenshotDocument(path, options, native = runNative) {
|
|
|
1177
1335
|
|
|
1178
1336
|
// src/cli.ts
|
|
1179
1337
|
var titleFor = (directory) => directory.split("/").filter(Boolean).at(-1)?.split(/[-_ ]+/).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ") || "My Slop";
|
|
1180
|
-
var parseCategories = (values) => values.map((value) => SlopCategorySchema
|
|
1338
|
+
var parseCategories = (values) => values.map((value) => validate(SlopCategorySchema, value.trim().toLowerCase()));
|
|
1181
1339
|
async function initMetadata(directory, flags) {
|
|
1340
|
+
const authorName = flags["author-name"]?.trim();
|
|
1341
|
+
const authorURL = flags["author-url"]?.trim();
|
|
1182
1342
|
const defaults = { title: flags.title || titleFor(directory), description: flags.description || "A small, lovable hitSlop app.", categories: parseCategories(flags.category?.length ? flags.category : ["utilities"]) };
|
|
1183
|
-
if (flags.yes || !process.stdin.isTTY)
|
|
1184
|
-
|
|
1343
|
+
if (flags.yes || !process.stdin.isTTY) {
|
|
1344
|
+
if (!authorName)
|
|
1345
|
+
throw new Error("--author-name is required with --yes or in a non-interactive terminal.");
|
|
1346
|
+
return { ...defaults, author: { name: authorName, ...authorURL ? { url: authorURL } : {} } };
|
|
1347
|
+
}
|
|
1185
1348
|
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
1186
1349
|
try {
|
|
1187
1350
|
const title = (await prompt.question(`Title (${defaults.title}): `)).trim() || defaults.title;
|
|
@@ -1190,7 +1353,11 @@ async function initMetadata(directory, flags) {
|
|
|
1190
1353
|
const categories = rawCategories ? parseCategories(rawCategories.split(",").filter(Boolean)) : defaults.categories;
|
|
1191
1354
|
if (categories.length < 1 || categories.length > 2)
|
|
1192
1355
|
throw new Error("Choose one or two categories.");
|
|
1193
|
-
|
|
1356
|
+
const name = authorName || (await prompt.question("Author name: ")).trim();
|
|
1357
|
+
if (!name)
|
|
1358
|
+
throw new Error("Author name is required.");
|
|
1359
|
+
const url = authorURL || (await prompt.question("Author URL (optional): ")).trim();
|
|
1360
|
+
return { title, description, categories, author: { name, ...url ? { url } : {} } };
|
|
1194
1361
|
} finally {
|
|
1195
1362
|
prompt.close();
|
|
1196
1363
|
}
|
|
@@ -1210,6 +1377,8 @@ app = app.command("init", (command) => command.meta({ description: "Create a Sve
|
|
|
1210
1377
|
template: { type: "string", description: "Svelte authoring template (svelte or svelte-counter)." },
|
|
1211
1378
|
title: { type: "string", description: "Manifest title." },
|
|
1212
1379
|
description: { type: "string", description: "Manifest description." },
|
|
1380
|
+
"author-name": { type: "string", description: "Required manifest author name." },
|
|
1381
|
+
"author-url": { type: "string", description: "Optional manifest author URL." },
|
|
1213
1382
|
category: { type: "string", multiple: true, description: "Manifest category; pass once or twice." },
|
|
1214
1383
|
yes: { type: "boolean", description: "Accept manifest defaults without prompting." }
|
|
1215
1384
|
}).run(async ({ args, flags }) => {
|
|
@@ -1218,7 +1387,7 @@ app = app.command("init", (command) => command.meta({ description: "Create a Sve
|
|
|
1218
1387
|
console.log(`Created ${args.directory}`);
|
|
1219
1388
|
}));
|
|
1220
1389
|
app = app.command("validate", (command) => command.meta({ description: "Validate an authoring project or built .slop document." }).args([{ name: "path", type: "path", default: "." }]).run(async ({ args }) => {
|
|
1221
|
-
const runtime = await
|
|
1390
|
+
const runtime = await stat6(join10(args.path, "app.html")).then((value) => value.isFile()).catch(() => false);
|
|
1222
1391
|
const manifest = runtime ? await validateRuntimePackage(args.path) : await validateAuthoringProject(args.path);
|
|
1223
1392
|
console.log(`valid ${manifest.slug}`);
|
|
1224
1393
|
}));
|
|
@@ -1235,7 +1404,7 @@ app = app.command("register", (command) => command.meta({ description: "Build an
|
|
|
1235
1404
|
const result = await installTemplate(args.path, { force: flags.force ?? false, ...flags.preview ? { preview: flags.preview } : {}, ...flags.icon ? { icon: flags.icon } : {}, confirmOverwrite: confirmReplacement });
|
|
1236
1405
|
console.log(`${result.replaced ? "Updated" : "Installed"} ${result.manifest.title} at ${result.directory}`);
|
|
1237
1406
|
}));
|
|
1238
|
-
app = app.command("publish", (command) => command.args([{ name: "path", type: "path", default: "." }]).flags({ registry: { type: "string", description: "Catalog publish endpoint. Defaults to https://api.hitslop.com/api/publish. Set HITSLOP_REGISTRY_URL=http://
|
|
1407
|
+
app = app.command("publish", (command) => command.args([{ name: "path", type: "path", default: "." }]).flags({ registry: { type: "string", description: "Catalog publish endpoint. Defaults to https://api.hitslop.com/api/publish. Set HITSLOP_REGISTRY_URL=http://127.0.0.1:5002/api/publish for the local Firebase emulators." }, preview: { type: "path" }, icon: { type: "path", description: "Use this 512x512 PNG as the catalog and Finder icon." } }).run(async ({ args, flags }) => {
|
|
1239
1408
|
console.log(await publishSlop(args.path, { ...flags.registry ? { registry: flags.registry } : {}, ...flags.preview ? { preview: flags.preview } : {}, ...flags.icon ? { icon: flags.icon } : {} }));
|
|
1240
1409
|
}));
|
|
1241
1410
|
app = app.command("export", (command) => command.meta({ description: "Export a built .slop document as a full-height PNG or PDF." }).args([{ name: "path", type: "path", default: "." }]).flags({
|
|
@@ -1266,7 +1435,7 @@ async function secret(prompt) {
|
|
|
1266
1435
|
process.stdout.write(prompt);
|
|
1267
1436
|
process.stdin.setRawMode(true);
|
|
1268
1437
|
process.stdin.resume();
|
|
1269
|
-
return new Promise((
|
|
1438
|
+
return new Promise((resolve8, reject) => {
|
|
1270
1439
|
let value = "";
|
|
1271
1440
|
const onData = (chunk) => {
|
|
1272
1441
|
for (const byte of chunk) {
|
|
@@ -1279,7 +1448,7 @@ async function secret(prompt) {
|
|
|
1279
1448
|
cleanup();
|
|
1280
1449
|
process.stdout.write(`
|
|
1281
1450
|
`);
|
|
1282
|
-
|
|
1451
|
+
resolve8(value);
|
|
1283
1452
|
return;
|
|
1284
1453
|
}
|
|
1285
1454
|
if (byte === 127 || byte === 8) {
|
|
@@ -1303,15 +1472,7 @@ async function runIdentity(arguments_) {
|
|
|
1303
1472
|
const action = arguments_[1] ?? "show";
|
|
1304
1473
|
if (action === "show") {
|
|
1305
1474
|
const identity = await getIdentity();
|
|
1306
|
-
console.log(
|
|
1307
|
-
return true;
|
|
1308
|
-
}
|
|
1309
|
-
if (action === "set-name") {
|
|
1310
|
-
const name = arguments_.slice(2).join(" ").trim();
|
|
1311
|
-
if (!name)
|
|
1312
|
-
throw new Error("Usage: slop identity set-name <name>");
|
|
1313
|
-
const identity = await setIdentityName(name);
|
|
1314
|
-
console.log(`${identity.keyId} ${identity.displayName}`);
|
|
1475
|
+
console.log(identity.keyId);
|
|
1315
1476
|
return true;
|
|
1316
1477
|
}
|
|
1317
1478
|
if (action === "export") {
|
|
@@ -1331,7 +1492,7 @@ async function runIdentity(arguments_) {
|
|
|
1331
1492
|
if (!path)
|
|
1332
1493
|
throw new Error("Usage: slop identity import <file> [--force]");
|
|
1333
1494
|
const identity = await importIdentity(path, await secret("Import passphrase: "), arguments_.includes("--force"));
|
|
1334
|
-
console.log(
|
|
1495
|
+
console.log(identity.keyId);
|
|
1335
1496
|
return true;
|
|
1336
1497
|
}
|
|
1337
1498
|
throw new Error(`Unknown identity command: ${action}`);
|