@hitslop/cli 0.1.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/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/cli.js +1340 -0
- package/package.json +43 -0
- package/templates/svelte/.agents/skills/hitslop-authoring/SKILL.md +47 -0
- package/templates/svelte/.agents/skills/hitslop-authoring/references/storage-and-packages.md +22 -0
- package/templates/svelte/.agents/skills/hitslop-authoring/references/workflow.md +46 -0
- package/templates/svelte/.agents/skills/hitslop-design/SKILL.md +62 -0
- package/templates/svelte/.agents/skills/hitslop-design/references/bits-ui-styling.md +95 -0
- package/templates/svelte/.agents/skills/hitslop-design/references/object-families.md +37 -0
- package/templates/svelte/.agents/skills/hitslop-design/references/presentation-and-export.md +42 -0
- package/templates/svelte/AGENTS.md +18 -0
- package/templates/svelte/assets/theme.css +10 -0
- package/templates/svelte/index.html +2 -0
- package/templates/svelte/schema.ts +5 -0
- package/templates/svelte/src/App.svelte +32 -0
- package/templates/svelte/src/main.ts +9 -0
- package/templates/svelte/src/styles.css.ts +45 -0
- package/templates/svelte/src/theme-contract.css.ts +12 -0
- package/templates/svelte/vite.config.ts +5 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1340 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
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
|
+
// package.json
|
|
22
|
+
var package_default = {
|
|
23
|
+
name: "@hitslop/cli",
|
|
24
|
+
version: "0.1.0",
|
|
25
|
+
description: "Create, develop, build, register, and publish hitSlop apps.",
|
|
26
|
+
license: "MIT",
|
|
27
|
+
repository: { type: "git", url: "git+https://github.com/hitslop/hitslop.git", directory: "packages/cli" },
|
|
28
|
+
homepage: "https://hitslop.app",
|
|
29
|
+
bugs: { url: "https://github.com/hitslop/hitslop/issues" },
|
|
30
|
+
keywords: ["hitslop", "cli", "local-first", "mini-apps"],
|
|
31
|
+
publishConfig: { access: "public" },
|
|
32
|
+
type: "module",
|
|
33
|
+
bin: {
|
|
34
|
+
slop: "./dist/cli.js"
|
|
35
|
+
},
|
|
36
|
+
files: ["dist", "templates"],
|
|
37
|
+
scripts: {
|
|
38
|
+
build: "bun ../../scripts/clean-dist.ts && bun build src/cli.ts --target bun --format esm --packages external --outfile dist/cli.js && /bin/chmod +x dist/cli.js",
|
|
39
|
+
check: "tsc -p tsconfig.json",
|
|
40
|
+
test: "bun run --cwd ../schema build && bun test"
|
|
41
|
+
},
|
|
42
|
+
dependencies: {
|
|
43
|
+
"@crustjs/core": "^0.0.19",
|
|
44
|
+
"@hitslop/schema": "workspace:*",
|
|
45
|
+
"@noble/ed25519": "^3.2.0",
|
|
46
|
+
"fast-png": "^8.0.0",
|
|
47
|
+
fflate: "^0.8.3",
|
|
48
|
+
postcss: "^8.5.6",
|
|
49
|
+
vite: "^8.2.2",
|
|
50
|
+
zod: "^4.5.2"
|
|
51
|
+
},
|
|
52
|
+
devDependencies: {
|
|
53
|
+
"@types/bun": "latest",
|
|
54
|
+
typescript: "^7.0.2"
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/document-skill.ts
|
|
59
|
+
import { lstat, mkdir, readFile, readdir, stat, writeFile } from "fs/promises";
|
|
60
|
+
import { dirname, join } from "path";
|
|
61
|
+
import {
|
|
62
|
+
documentGuidePath,
|
|
63
|
+
documentGuideSource,
|
|
64
|
+
documentSkillContent,
|
|
65
|
+
documentSkillPath,
|
|
66
|
+
isCanonicalDocumentSkill,
|
|
67
|
+
maxDocumentGuideBytes
|
|
68
|
+
} from "@hitslop/schema";
|
|
69
|
+
var decoder = new TextDecoder("utf-8", { fatal: true });
|
|
70
|
+
var exists = async (path) => stat(path).then(() => true).catch(() => false);
|
|
71
|
+
async function emitDocumentSkill(sourceRoot, outputRoot) {
|
|
72
|
+
const skill = join(outputRoot, documentSkillPath);
|
|
73
|
+
await mkdir(dirname(skill), { recursive: true });
|
|
74
|
+
await writeFile(skill, documentSkillContent);
|
|
75
|
+
const guide = await readDocumentGuideSource(sourceRoot);
|
|
76
|
+
if (!guide)
|
|
77
|
+
return;
|
|
78
|
+
const destination = join(outputRoot, documentGuidePath);
|
|
79
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
80
|
+
await writeFile(destination, guide);
|
|
81
|
+
}
|
|
82
|
+
async function readDocumentGuideSource(sourceRoot) {
|
|
83
|
+
const guideSource = join(sourceRoot, documentGuideSource);
|
|
84
|
+
if (!await exists(guideSource))
|
|
85
|
+
return;
|
|
86
|
+
const info = await lstat(guideSource);
|
|
87
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
88
|
+
throw new Error(`${documentGuideSource} must be a regular UTF-8 Markdown file.`);
|
|
89
|
+
const guide = await readFile(guideSource);
|
|
90
|
+
if (guide.byteLength > maxDocumentGuideBytes)
|
|
91
|
+
throw new Error(`${documentGuideSource} cannot exceed 32 KiB.`);
|
|
92
|
+
try {
|
|
93
|
+
decoder.decode(guide);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error(`${documentGuideSource} must be UTF-8.`);
|
|
96
|
+
}
|
|
97
|
+
return guide;
|
|
98
|
+
}
|
|
99
|
+
async function validateDocumentSkill(root) {
|
|
100
|
+
const agents = join(root, ".agents");
|
|
101
|
+
if (!await exists(agents)) {
|
|
102
|
+
throw new Error(`Runtime package is missing ${documentSkillPath}.`);
|
|
103
|
+
}
|
|
104
|
+
await requireDirectory(agents, ".agents");
|
|
105
|
+
await requireOnly(agents, new Set(["skills"]), ".agents");
|
|
106
|
+
const skills = join(agents, "skills");
|
|
107
|
+
await requireDirectory(skills, ".agents/skills");
|
|
108
|
+
await requireOnly(skills, new Set(["hitslop-document"]), ".agents/skills");
|
|
109
|
+
const folder = join(skills, "hitslop-document");
|
|
110
|
+
await requireDirectory(folder, ".agents/skills/hitslop-document");
|
|
111
|
+
await requireOnly(folder, new Set(["SKILL.md", "references"]), ".agents/skills/hitslop-document", true);
|
|
112
|
+
const skillPath = join(root, documentSkillPath);
|
|
113
|
+
const skillInfo = await lstat(skillPath).catch(() => {
|
|
114
|
+
return;
|
|
115
|
+
});
|
|
116
|
+
if (!skillInfo?.isFile() || skillInfo.isSymbolicLink())
|
|
117
|
+
throw new Error(`${documentSkillPath} must be a regular file.`);
|
|
118
|
+
let canonical = false;
|
|
119
|
+
try {
|
|
120
|
+
canonical = isCanonicalDocumentSkill(await readFile(skillPath));
|
|
121
|
+
} catch {
|
|
122
|
+
throw new Error(`${documentSkillPath} must be UTF-8.`);
|
|
123
|
+
}
|
|
124
|
+
if (!canonical)
|
|
125
|
+
throw new Error(`${documentSkillPath} is not a recognized canonical hitSlop document skill.`);
|
|
126
|
+
const references = join(folder, "references");
|
|
127
|
+
if (!await exists(references))
|
|
128
|
+
return;
|
|
129
|
+
await requireDirectory(references, ".agents/skills/hitslop-document/references");
|
|
130
|
+
await requireOnly(references, new Set(["app-guide.md"]), ".agents/skills/hitslop-document/references");
|
|
131
|
+
const guide = join(root, documentGuidePath);
|
|
132
|
+
const guideInfo = await lstat(guide);
|
|
133
|
+
if (!guideInfo.isFile() || guideInfo.isSymbolicLink())
|
|
134
|
+
throw new Error(`${documentGuidePath} must be a regular file.`);
|
|
135
|
+
const contents = await readFile(guide);
|
|
136
|
+
if (contents.byteLength > maxDocumentGuideBytes)
|
|
137
|
+
throw new Error(`${documentGuidePath} cannot exceed 32 KiB.`);
|
|
138
|
+
try {
|
|
139
|
+
decoder.decode(contents);
|
|
140
|
+
} catch {
|
|
141
|
+
throw new Error(`${documentGuidePath} must be UTF-8.`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function requireDirectory(path, label) {
|
|
145
|
+
const info = await lstat(path).catch(() => {
|
|
146
|
+
return;
|
|
147
|
+
});
|
|
148
|
+
if (!info?.isDirectory() || info.isSymbolicLink())
|
|
149
|
+
throw new Error(`${label} must be a directory, not a symlink.`);
|
|
150
|
+
}
|
|
151
|
+
async function requireOnly(directory, allowed, label, optionalReferences = false) {
|
|
152
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
153
|
+
if (entry.isSymbolicLink())
|
|
154
|
+
throw new Error(`${label} cannot contain symlinks.`);
|
|
155
|
+
if (!allowed.has(entry.name) || optionalReferences && entry.name === "references" && !entry.isDirectory()) {
|
|
156
|
+
throw new Error(`${label} cannot contain ${entry.name}.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/theme.ts
|
|
162
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
163
|
+
import { join as join2 } from "path";
|
|
164
|
+
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
|
+
async function readUTF8(path, label) {
|
|
168
|
+
try {
|
|
169
|
+
return decoder2.decode(await readFile2(path));
|
|
170
|
+
} catch {
|
|
171
|
+
throw new Error(`${label} must be UTF-8.`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function readThemeContract(root) {
|
|
175
|
+
const path = join2(root, "assets", "theme.css");
|
|
176
|
+
if (!await exists2(path))
|
|
177
|
+
return;
|
|
178
|
+
const source = await readUTF8(path, "assets/theme.css");
|
|
179
|
+
const contract = parseTheme(source, "assets/theme.css");
|
|
180
|
+
validateThemeReferences(source, contract, "assets/theme.css");
|
|
181
|
+
return contract;
|
|
182
|
+
}
|
|
183
|
+
async function validateThemeOverride(root, contract) {
|
|
184
|
+
const path = join2(root, "stores", "theme.css");
|
|
185
|
+
if (!await exists2(path))
|
|
186
|
+
return;
|
|
187
|
+
if (!contract)
|
|
188
|
+
throw new Error("stores/theme.css requires an immutable assets/theme.css contract.");
|
|
189
|
+
const source = await readUTF8(path, "stores/theme.css");
|
|
190
|
+
const overrides = parseTheme(source, "stores/theme.css");
|
|
191
|
+
for (const property of overrides)
|
|
192
|
+
if (!contract.has(property))
|
|
193
|
+
throw new Error(`stores/theme.css cannot define unknown theme variable ${property}.`);
|
|
194
|
+
validateThemeReferences(source, contract, "stores/theme.css");
|
|
195
|
+
}
|
|
196
|
+
function validateThemeReferences(cssOrHTML, contract, label) {
|
|
197
|
+
const referenced = new Set([...cssOrHTML.matchAll(/var\(\s*(--slop-[a-z0-9-]+)/gi)].map((match) => match[1]));
|
|
198
|
+
if (!referenced.size)
|
|
199
|
+
return;
|
|
200
|
+
if (!contract)
|
|
201
|
+
throw new Error(`${label} references public --slop-* variables but assets/theme.css is missing.`);
|
|
202
|
+
for (const property of referenced)
|
|
203
|
+
if (!contract.has(property))
|
|
204
|
+
throw new Error(`${label} references ${property}, but assets/theme.css does not provide a default.`);
|
|
205
|
+
}
|
|
206
|
+
function parseTheme(source, label) {
|
|
207
|
+
let root;
|
|
208
|
+
try {
|
|
209
|
+
root = postcss.parse(source, { from: label });
|
|
210
|
+
} catch (error) {
|
|
211
|
+
throw new Error(`${label} is not valid CSS: ${error instanceof Error ? error.message : String(error)}`);
|
|
212
|
+
}
|
|
213
|
+
const rules = root.nodes.filter((node) => node.type !== "comment");
|
|
214
|
+
if (rules.length !== 1 || rules[0]?.type !== "rule" || rules[0].selector.trim() !== ":root") {
|
|
215
|
+
throw new Error(`${label} must contain exactly one :root rule.`);
|
|
216
|
+
}
|
|
217
|
+
const properties = new Set;
|
|
218
|
+
for (const node of rules[0].nodes) {
|
|
219
|
+
if (node.type === "comment")
|
|
220
|
+
continue;
|
|
221
|
+
if (node.type !== "decl")
|
|
222
|
+
throw new Error(`${label} can contain only custom-property declarations.`);
|
|
223
|
+
if (!/^--slop-[a-z0-9-]+$/.test(node.prop))
|
|
224
|
+
throw new Error(`${label} can define only lowercase --slop-* custom properties.`);
|
|
225
|
+
if (!node.value.trim())
|
|
226
|
+
throw new Error(`${label} cannot leave ${node.prop} empty.`);
|
|
227
|
+
if (properties.has(node.prop))
|
|
228
|
+
throw new Error(`${label} defines ${node.prop} more than once.`);
|
|
229
|
+
properties.add(node.prop);
|
|
230
|
+
}
|
|
231
|
+
if (!properties.size)
|
|
232
|
+
throw new Error(`${label} must define at least one --slop-* custom property.`);
|
|
233
|
+
return properties;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/vite-build-plugin.ts
|
|
237
|
+
import { version as viteVersion } from "vite";
|
|
238
|
+
var escapePattern = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
239
|
+
function inlineScript(html, fileName, code) {
|
|
240
|
+
const name = escapePattern(fileName);
|
|
241
|
+
const tag = new RegExp(`<script([^>]*?)\\ssrc=["'](?:[^"']*\\/)?${name}["']([^>]*)>\\s*</script>`, "g");
|
|
242
|
+
const safeCode = code.replace(/"?__VITE_PRELOAD__"?/g, "void 0").replace(/<(\/script>|!--)/g, "\\x3C$1");
|
|
243
|
+
return html.replace(tag, (_match, before, after) => `<script${before}${after}>${safeCode.trim()}</script>`);
|
|
244
|
+
}
|
|
245
|
+
function inlineStyle(html, fileName, css) {
|
|
246
|
+
const name = escapePattern(fileName);
|
|
247
|
+
const tag = new RegExp(`<link([^>]*?)\\shref=["'](?:[^"']*\\/)?${name}["']([^>]*)>`, "g");
|
|
248
|
+
return html.replace(tag, (_match, before, after) => `<style${before}${after}>${css.replace('@charset "UTF-8";', "").trim()}</style>`);
|
|
249
|
+
}
|
|
250
|
+
var addThemeLinks = (html) => {
|
|
251
|
+
if (html.includes("data-hitslop-theme-default"))
|
|
252
|
+
return html;
|
|
253
|
+
const links = '<link rel="stylesheet" href="assets/theme.css" data-hitslop-theme-default><link rel="stylesheet" href="theme.css" data-hitslop-theme>';
|
|
254
|
+
return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, `${links}</head>`) : links + html;
|
|
255
|
+
};
|
|
256
|
+
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
|
+
return {
|
|
275
|
+
name: "hitslop:build",
|
|
276
|
+
enforce: "post",
|
|
277
|
+
config: configure,
|
|
278
|
+
generateBundle(_output, bundle) {
|
|
279
|
+
const htmlAssets = Object.values(bundle).filter((item) => item.type === "asset" && item.fileName.endsWith(".html"));
|
|
280
|
+
const scripts = Object.values(bundle).filter((item) => item.type === "chunk" && /\.[mc]?js$/.test(item.fileName));
|
|
281
|
+
const styles = Object.values(bundle).filter((item) => item.type === "asset" && item.fileName.endsWith(".css"));
|
|
282
|
+
const consumed = new Set;
|
|
283
|
+
for (const asset of htmlAssets) {
|
|
284
|
+
let html = typeof asset.source === "string" ? asset.source : new TextDecoder().decode(asset.source);
|
|
285
|
+
for (const script of scripts) {
|
|
286
|
+
html = inlineScript(html, script.fileName, script.code);
|
|
287
|
+
consumed.add(script.fileName);
|
|
288
|
+
}
|
|
289
|
+
for (const style of styles) {
|
|
290
|
+
const css = typeof style.source === "string" ? style.source : new TextDecoder().decode(style.source);
|
|
291
|
+
html = inlineStyle(html, style.fileName, css);
|
|
292
|
+
consumed.add(style.fileName);
|
|
293
|
+
}
|
|
294
|
+
if (options.hasTheme)
|
|
295
|
+
html = addThemeLinks(html);
|
|
296
|
+
asset.source = html;
|
|
297
|
+
}
|
|
298
|
+
for (const fileName of consumed)
|
|
299
|
+
delete bundle[fileName];
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// src/project.ts
|
|
305
|
+
var manifestPath = (root) => join3(root, "manifest.json");
|
|
306
|
+
async function loadManifest(root) {
|
|
307
|
+
const manifest = parseManifest(JSON.parse(await readFile3(manifestPath(root), "utf8")));
|
|
308
|
+
await validateSkin(root, manifest);
|
|
309
|
+
return manifest;
|
|
310
|
+
}
|
|
311
|
+
var exists3 = async (path) => {
|
|
312
|
+
try {
|
|
313
|
+
await stat3(path);
|
|
314
|
+
return true;
|
|
315
|
+
} catch {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
var sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
320
|
+
async function validateSkin(root, manifest) {
|
|
321
|
+
if (!("skin" in manifest.presentation))
|
|
322
|
+
return;
|
|
323
|
+
const path = join3(root, manifest.presentation.skin);
|
|
324
|
+
if (!await exists3(path))
|
|
325
|
+
throw new Error(`Missing window skin: ${manifest.presentation.skin}`);
|
|
326
|
+
const info = await lstat2(path);
|
|
327
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
328
|
+
throw new Error("The window skin must be a regular file, not a symlink.");
|
|
329
|
+
let png;
|
|
330
|
+
try {
|
|
331
|
+
png = decodePng(await readFile3(path), { checkCrc: true });
|
|
332
|
+
} catch {
|
|
333
|
+
throw new Error("The window skin must be a valid PNG image.");
|
|
334
|
+
}
|
|
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
|
+
if (png.channels !== 4)
|
|
339
|
+
throw new Error("The window skin must be an RGBA PNG image.");
|
|
340
|
+
}
|
|
341
|
+
async function scaffold(destination, options = {}) {
|
|
342
|
+
const template = options.template ?? "svelte-counter";
|
|
343
|
+
if (!new Set(["svelte", "svelte-counter"]).has(template))
|
|
344
|
+
throw new Error(`Unknown authoring template: ${template}`);
|
|
345
|
+
if (await exists3(destination) && (await readdir2(destination)).length)
|
|
346
|
+
throw new Error(`Destination is not empty: ${destination}`);
|
|
347
|
+
const slug = basename(destination).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "my-slop";
|
|
348
|
+
const defaultTitle = slug.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
|
|
349
|
+
const title = options.title || defaultTitle;
|
|
350
|
+
const templateRoot = fileURLToPath(new URL("../templates/svelte", import.meta.url));
|
|
351
|
+
await cp(templateRoot, destination, { recursive: true });
|
|
352
|
+
await writeFile2(join3(destination, "package.json"), JSON.stringify({
|
|
353
|
+
name: slug,
|
|
354
|
+
private: true,
|
|
355
|
+
type: "module",
|
|
356
|
+
scripts: {
|
|
357
|
+
dev: "slop dev",
|
|
358
|
+
validate: "slop validate",
|
|
359
|
+
build: "slop build",
|
|
360
|
+
register: "slop register",
|
|
361
|
+
publish: "slop publish"
|
|
362
|
+
},
|
|
363
|
+
dependencies: { "@hitslop/runtime": `^${package_default.version}`, "@hitslop/svelte": `^${package_default.version}`, "bits-ui": "^2.19.0", svelte: "^5.0.0", zod: "^4.5.2" },
|
|
364
|
+
devDependencies: { "@hitslop/cli": `^${package_default.version}`, "@sveltejs/vite-plugin-svelte": "^7.0.0", "@vanilla-extract/css": "^1.17.4", "@vanilla-extract/vite-plugin": "^5.1.1", vite: "^8.0.0" }
|
|
365
|
+
}, null, 2) + `
|
|
366
|
+
`);
|
|
367
|
+
await writeFile2(join3(destination, "manifest.json"), JSON.stringify({
|
|
368
|
+
$schema: manifestSchemaURL,
|
|
369
|
+
slug,
|
|
370
|
+
title,
|
|
371
|
+
description: options.description || "A small, lovable hitSlop app.",
|
|
372
|
+
categories: options.categories?.length ? options.categories : ["utilities"],
|
|
373
|
+
presentation: { width: 560, height: 420 }
|
|
374
|
+
}, null, 2) + `
|
|
375
|
+
`);
|
|
376
|
+
const appPath = join3(destination, "src/App.svelte");
|
|
377
|
+
await writeFile2(appPath, (await readFile3(appPath, "utf8")).replace("__SLOP_TITLE_LITERAL__", JSON.stringify(title)));
|
|
378
|
+
}
|
|
379
|
+
async function buildSlop(root) {
|
|
380
|
+
const manifest = await loadManifest(root);
|
|
381
|
+
const dataSchema = await generateDataSchema(root);
|
|
382
|
+
await readDocumentGuideSource(root);
|
|
383
|
+
const distRoot = join3(root, "dist");
|
|
384
|
+
const output = join3(distRoot, `${manifest.slug}.slop`);
|
|
385
|
+
const viteOut = join3(root, ".hitslop", "vite-build");
|
|
386
|
+
const themeContract = await readThemeContract(root);
|
|
387
|
+
const hasTheme = themeContract !== undefined;
|
|
388
|
+
await rm(output, { recursive: true, force: true });
|
|
389
|
+
await rm(viteOut, { recursive: true, force: true });
|
|
390
|
+
await mkdir2(output, { recursive: true });
|
|
391
|
+
await viteBuild({ root, plugins: [hitSlopBuildPlugin({ hasTheme })], build: { outDir: viteOut, emptyOutDir: true } });
|
|
392
|
+
const html = join3(viteOut, "index.html");
|
|
393
|
+
if (!await exists3(html))
|
|
394
|
+
throw new Error("Vite did not produce index.html");
|
|
395
|
+
validateThemeReferences(await readFile3(html, "utf8"), themeContract, "generated app.html");
|
|
396
|
+
await cp(html, join3(output, "app.html"));
|
|
397
|
+
await writeFile2(join3(output, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
398
|
+
`);
|
|
399
|
+
for (const name of ["assets"]) {
|
|
400
|
+
const source = join3(root, name);
|
|
401
|
+
if (await exists3(source))
|
|
402
|
+
await copyRuntimeTree(source, join3(output, name));
|
|
403
|
+
}
|
|
404
|
+
if (dataSchema)
|
|
405
|
+
await writeFile2(join3(output, "data.schema.json"), `${JSON.stringify(dataSchema, null, 2)}
|
|
406
|
+
`);
|
|
407
|
+
await emitDocumentSkill(root, output);
|
|
408
|
+
await rm(viteOut, { recursive: true, force: true });
|
|
409
|
+
return { directory: output, manifest };
|
|
410
|
+
}
|
|
411
|
+
async function generateDataSchema(root) {
|
|
412
|
+
const source = join3(root, "schema.ts");
|
|
413
|
+
if (!await exists3(source))
|
|
414
|
+
return;
|
|
415
|
+
const imported = await import(`${pathToFileURL(source).href}?hitslop=${Date.now()}`);
|
|
416
|
+
if (!imported.default)
|
|
417
|
+
throw new Error("schema.ts must default-export a Zod 4 schema");
|
|
418
|
+
let schema;
|
|
419
|
+
try {
|
|
420
|
+
schema = z.toJSONSchema(imported.default, { target: "draft-2020-12", io: "input" });
|
|
421
|
+
} catch (error) {
|
|
422
|
+
throw new Error(`Could not generate data.schema.json from schema.ts: ${error instanceof Error ? error.message : String(error)}`);
|
|
423
|
+
}
|
|
424
|
+
return schema;
|
|
425
|
+
}
|
|
426
|
+
async function validateAuthoringProject(root) {
|
|
427
|
+
const manifest = await loadManifest(root);
|
|
428
|
+
await readThemeContract(root);
|
|
429
|
+
await generateDataSchema(root);
|
|
430
|
+
await readDocumentGuideSource(root);
|
|
431
|
+
return manifest;
|
|
432
|
+
}
|
|
433
|
+
async function walk(root, directory = root) {
|
|
434
|
+
const result = [];
|
|
435
|
+
for (const entry of await readdir2(directory, { withFileTypes: true })) {
|
|
436
|
+
const absolute = join3(directory, entry.name);
|
|
437
|
+
const rel = relative(root, absolute).split(sep).join("/");
|
|
438
|
+
if (entry.isSymbolicLink())
|
|
439
|
+
throw new Error(`Symlinks are not allowed in artifacts: ${rel}`);
|
|
440
|
+
if (entry.isDirectory())
|
|
441
|
+
result.push(...await walk(root, absolute));
|
|
442
|
+
else if (entry.isFile())
|
|
443
|
+
result.push(rel);
|
|
444
|
+
}
|
|
445
|
+
return result.sort();
|
|
446
|
+
}
|
|
447
|
+
async function packSlop(directory) {
|
|
448
|
+
const files = {};
|
|
449
|
+
const epoch = new Date(2000, 0, 1, 0, 0, 0);
|
|
450
|
+
for (const path of await walk(directory))
|
|
451
|
+
files[path] = [new Uint8Array(await Bun.file(join3(directory, path)).arrayBuffer()), { mtime: epoch, level: 9 }];
|
|
452
|
+
const bytes = zipSync(files, { level: 9 });
|
|
453
|
+
return { bytes, sha256: sha256(bytes) };
|
|
454
|
+
}
|
|
455
|
+
async function copyRuntimeTree(source, destination) {
|
|
456
|
+
if ((await lstat2(source)).isSymbolicLink())
|
|
457
|
+
throw new Error(`Symlinks are not allowed in artifacts: ${source}`);
|
|
458
|
+
const info = await stat3(source);
|
|
459
|
+
if (info.isDirectory()) {
|
|
460
|
+
await mkdir2(destination, { recursive: true });
|
|
461
|
+
for (const entry of await readdir2(source))
|
|
462
|
+
await copyRuntimeTree(join3(source, entry), join3(destination, entry));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
await mkdir2(dirname2(destination), { recursive: true });
|
|
466
|
+
await cp(source, destination);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/dev.ts
|
|
470
|
+
import { stat as stat4 } from "fs/promises";
|
|
471
|
+
import { join as join4, resolve as resolve2 } from "path";
|
|
472
|
+
import { createServer } from "vite";
|
|
473
|
+
|
|
474
|
+
// src/dev-bridge.ts
|
|
475
|
+
var hostStyle = `<style data-hitslop-host>*{scrollbar-width:none!important}*::-webkit-scrollbar{width:0!important;height:0!important;display:none!important}</style>`;
|
|
476
|
+
var devHostJavaScript = `(() => {
|
|
477
|
+
const listeners = { json: new Set(), sqlite: new Set(), media: new Set() };
|
|
478
|
+
let jsonValue;
|
|
479
|
+
let jsonRevision = 0;
|
|
480
|
+
let jsonOpened = false;
|
|
481
|
+
let mediaRevision = 0;
|
|
482
|
+
|
|
483
|
+
const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
484
|
+
const emit = (kind, event) => listeners[kind].forEach((callback) => callback({ kind, ...event }));
|
|
485
|
+
const watch = (kind, callback) => {
|
|
486
|
+
listeners[kind].add(callback);
|
|
487
|
+
return () => listeners[kind].delete(callback);
|
|
488
|
+
};
|
|
489
|
+
const revision = () => 'dev:' + jsonRevision;
|
|
490
|
+
|
|
491
|
+
const resize = async (size) => size;
|
|
492
|
+
const drag = async () => undefined;
|
|
493
|
+
|
|
494
|
+
window.slop = Object.freeze({
|
|
495
|
+
json: Object.freeze({
|
|
496
|
+
open: async (value) => {
|
|
497
|
+
if (!jsonOpened) { jsonValue = clone(value); jsonOpened = true; }
|
|
498
|
+
return { value: clone(jsonValue), revision: revision() };
|
|
499
|
+
},
|
|
500
|
+
read: async () => {
|
|
501
|
+
if (!jsonOpened) throw new Error('JSON preview store has not been opened');
|
|
502
|
+
return { value: clone(jsonValue), revision: revision() };
|
|
503
|
+
},
|
|
504
|
+
write: async (value, expectedRevision) => {
|
|
505
|
+
if (expectedRevision && expectedRevision !== revision()) throw new Error('revision_conflict');
|
|
506
|
+
jsonValue = clone(value); jsonOpened = true; jsonRevision += 1;
|
|
507
|
+
const result = { revision: revision() };
|
|
508
|
+
emit('json', { source: 'app', revision: result.revision });
|
|
509
|
+
return result;
|
|
510
|
+
},
|
|
511
|
+
onChange: (callback) => watch('json', callback)
|
|
512
|
+
}),
|
|
513
|
+
db: Object.freeze({
|
|
514
|
+
query: async () => [],
|
|
515
|
+
execute: async () => { emit('sqlite', { source: 'app' }); return 0; },
|
|
516
|
+
transaction: async () => { emit('sqlite', { source: 'app' }); return 0; },
|
|
517
|
+
onChange: (callback) => watch('sqlite', callback)
|
|
518
|
+
}),
|
|
519
|
+
media: Object.freeze({
|
|
520
|
+
open: async () => ({ exists: false, revision: null }),
|
|
521
|
+
write: async () => {
|
|
522
|
+
mediaRevision += 1;
|
|
523
|
+
const result = { revision: 'dev-media:' + mediaRevision };
|
|
524
|
+
emit('media', { source: 'app', revision: result.revision });
|
|
525
|
+
return result;
|
|
526
|
+
},
|
|
527
|
+
remove: async () => {
|
|
528
|
+
emit('media', { source: 'app', revision: null });
|
|
529
|
+
return { revision: null };
|
|
530
|
+
},
|
|
531
|
+
onChange: (callback) => watch('media', callback)
|
|
532
|
+
}),
|
|
533
|
+
window: Object.freeze({ resize, drag }),
|
|
534
|
+
ready: () => {
|
|
535
|
+
document.documentElement.dataset.hitslopReady = 'true';
|
|
536
|
+
window.dispatchEvent(new Event('slop:ready'));
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
})();`;
|
|
540
|
+
var bridgeScript = `<script>${devHostJavaScript}</script>`;
|
|
541
|
+
var injectHost = (html, options = {}) => {
|
|
542
|
+
const theme = options.themeHref ? `<link rel="stylesheet" href="${options.themeHref}" data-hitslop-theme-default>` : "";
|
|
543
|
+
const payload = hostStyle + theme + bridgeScript;
|
|
544
|
+
return /<head(?:\s[^>]*)?>/i.test(html) ? html.replace(/<head(?:\s[^>]*)?>/i, (tag) => tag + payload) : payload + html;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// src/dev.ts
|
|
548
|
+
var exists4 = async (path) => {
|
|
549
|
+
try {
|
|
550
|
+
await stat4(path);
|
|
551
|
+
return true;
|
|
552
|
+
} catch {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
function mockHostPlugin(options = {}) {
|
|
557
|
+
return {
|
|
558
|
+
name: "hitslop-browser-preview",
|
|
559
|
+
transformIndexHtml: {
|
|
560
|
+
order: "pre",
|
|
561
|
+
handler: (html) => injectHost(html, options)
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
async function runDev(root) {
|
|
566
|
+
await loadManifest(root);
|
|
567
|
+
const themeHref = await exists4(join4(root, "assets", "theme.css")) ? "/assets/theme.css" : undefined;
|
|
568
|
+
const server = await createServer({ root, plugins: [mockHostPlugin(themeHref ? { themeHref } : {})] });
|
|
569
|
+
await server.listen();
|
|
570
|
+
const url = server.resolvedUrls?.local[0];
|
|
571
|
+
if (!url)
|
|
572
|
+
throw new Error("Vite did not expose a development URL");
|
|
573
|
+
console.log(`hitSlop UI preview: ${url}`);
|
|
574
|
+
if (process.platform === "darwin")
|
|
575
|
+
Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore" });
|
|
576
|
+
}
|
|
577
|
+
async function runNative(arguments_) {
|
|
578
|
+
const override = process.env.HITSLOP_NATIVE_CLI;
|
|
579
|
+
const arch = process.arch === "arm64" ? "arm64-apple-macosx" : "x86_64-apple-macosx";
|
|
580
|
+
const repoDebugBin = resolve2(import.meta.dir, `../../../apps/apple/Packages/HitSlopApple/.build/${arch}/debug/hitslop-native`);
|
|
581
|
+
const repoReleaseBin = resolve2(import.meta.dir, `../../../apps/apple/Packages/HitSlopApple/.build/${arch}/release/hitslop-native`);
|
|
582
|
+
const candidates = [override, "/Applications/hitSlop.app/Contents/Helpers/hitslop-native", `${process.env.HOME}/Applications/hitSlop.app/Contents/Helpers/hitslop-native`, repoDebugBin, repoReleaseBin, "hitslop-native"].filter(Boolean);
|
|
583
|
+
for (const executable of candidates) {
|
|
584
|
+
try {
|
|
585
|
+
const processResult = Bun.spawn([executable, ...arguments_], { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
586
|
+
const status = await processResult.exited;
|
|
587
|
+
if (status === 0)
|
|
588
|
+
return;
|
|
589
|
+
if (executable === candidates.at(-1))
|
|
590
|
+
throw new Error(`hitslop-native failed with status ${status}`);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (executable === candidates.at(-1))
|
|
593
|
+
throw error;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
throw new Error("Install hitSlop or set HITSLOP_NATIVE_CLI to use native rendering.");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/publish.ts
|
|
600
|
+
import { join as join8, resolve as resolve5 } from "path";
|
|
601
|
+
import { cp as cp3, mkdir as mkdir5, rm as rm3 } from "fs/promises";
|
|
602
|
+
import { canonicalPublishEnvelope } from "@hitslop/schema";
|
|
603
|
+
|
|
604
|
+
// src/identity.ts
|
|
605
|
+
import { getPublicKeyAsync, keygenAsync, signAsync } from "@noble/ed25519";
|
|
606
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
|
607
|
+
import { chmod, mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
608
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
609
|
+
import { homedir, userInfo } from "os";
|
|
610
|
+
var service = "app.hitslop.cli";
|
|
611
|
+
var account = "publisher";
|
|
612
|
+
var fallback = join5(homedir(), ".hitslop", "identity.json");
|
|
613
|
+
var encode = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
614
|
+
var decode = (value) => new Uint8Array(Buffer.from(value, "base64url"));
|
|
615
|
+
async function keychainRead() {
|
|
616
|
+
if (process.platform !== "darwin")
|
|
617
|
+
return null;
|
|
618
|
+
const result = Bun.spawnSync(["security", "find-generic-password", "-s", service, "-a", account, "-w"]);
|
|
619
|
+
return result.exitCode === 0 ? result.stdout.toString().trim() : null;
|
|
620
|
+
}
|
|
621
|
+
async function keychainWrite(value) {
|
|
622
|
+
if (process.platform !== "darwin")
|
|
623
|
+
return false;
|
|
624
|
+
const result = Bun.spawnSync(["security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", value]);
|
|
625
|
+
return result.exitCode === 0;
|
|
626
|
+
}
|
|
627
|
+
async function readStored() {
|
|
628
|
+
const value = await keychainRead() ?? await readFile4(fallback, "utf8").catch(() => null);
|
|
629
|
+
return value ? JSON.parse(value) : null;
|
|
630
|
+
}
|
|
631
|
+
async function storeIdentity(identity) {
|
|
632
|
+
const json = JSON.stringify(identity);
|
|
633
|
+
if (!await keychainWrite(json)) {
|
|
634
|
+
await mkdir3(join5(homedir(), ".hitslop"), { recursive: true });
|
|
635
|
+
await writeFile3(fallback, json, { mode: 384 });
|
|
636
|
+
await chmod(fallback, 384);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
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
|
+
const derivedPublic = await getPublicKeyAsync(decode(identity.privateKey));
|
|
643
|
+
if (encode(derivedPublic) !== identity.publicKey)
|
|
644
|
+
throw new Error("Identity public and private keys do not match.");
|
|
645
|
+
if (sha256(derivedPublic).slice(0, 32) !== identity.keyId)
|
|
646
|
+
throw new Error("Identity key ID does not match its public key.");
|
|
647
|
+
return { ...identity, displayName: identity.displayName.trim() };
|
|
648
|
+
}
|
|
649
|
+
async function getIdentity() {
|
|
650
|
+
const stored = await readStored();
|
|
651
|
+
if (stored)
|
|
652
|
+
return validated(stored);
|
|
653
|
+
const pair = await keygenAsync();
|
|
654
|
+
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), displayName: userInfo().username });
|
|
656
|
+
await storeIdentity(identity);
|
|
657
|
+
return identity;
|
|
658
|
+
}
|
|
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
|
+
async function exportIdentity(path, passphrase) {
|
|
666
|
+
if (passphrase.length < 10)
|
|
667
|
+
throw new Error("Identity export passphrases must contain at least 10 characters.");
|
|
668
|
+
const identity = await getIdentity();
|
|
669
|
+
const salt = randomBytes(16);
|
|
670
|
+
const iv = randomBytes(12);
|
|
671
|
+
const key = scryptSync(passphrase, salt, 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
|
|
672
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
673
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(identity), "utf8"), cipher.final()]);
|
|
674
|
+
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 = resolve3(path);
|
|
676
|
+
await writeFile3(output, `${JSON.stringify(payload, null, 2)}
|
|
677
|
+
`, { mode: 384 });
|
|
678
|
+
await chmod(output, 384);
|
|
679
|
+
}
|
|
680
|
+
async function importIdentity(path, passphrase, force = false) {
|
|
681
|
+
if (await readStored() && !force)
|
|
682
|
+
throw new Error("A publisher identity already exists. Pass --force to replace it.");
|
|
683
|
+
const payload = JSON.parse(await readFile4(resolve3(path), "utf8"));
|
|
684
|
+
if (payload.version !== 1 || payload.kdf !== "scrypt" || payload.cipher !== "aes-256-gcm")
|
|
685
|
+
throw new Error("Unsupported identity export format.");
|
|
686
|
+
const key = scryptSync(passphrase, decode(payload.salt), 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
|
|
687
|
+
const decipher = createDecipheriv("aes-256-gcm", key, decode(payload.iv));
|
|
688
|
+
decipher.setAuthTag(decode(payload.tag));
|
|
689
|
+
let identity;
|
|
690
|
+
try {
|
|
691
|
+
identity = JSON.parse(Buffer.concat([decipher.update(decode(payload.ciphertext)), decipher.final()]).toString("utf8"));
|
|
692
|
+
} catch {
|
|
693
|
+
throw new Error("Could not decrypt identity; check the passphrase and file.");
|
|
694
|
+
}
|
|
695
|
+
const checked = await validated(identity);
|
|
696
|
+
await storeIdentity(checked);
|
|
697
|
+
return checked;
|
|
698
|
+
}
|
|
699
|
+
var sign = async (message, identity) => encode(await signAsync(message, decode(identity.privateKey)));
|
|
700
|
+
|
|
701
|
+
// src/install.ts
|
|
702
|
+
import { chmod as chmod2, cp as cp2, lstat as lstat4, mkdir as mkdir4, readFile as readFile7, readdir as readdir4, rename, rm as rm2, stat as stat6 } from "fs/promises";
|
|
703
|
+
import { randomUUID } from "crypto";
|
|
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
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/runtime-package.ts
|
|
773
|
+
var decoder3 = new TextDecoder("utf-8", { fatal: true });
|
|
774
|
+
var exists5 = async (path) => stat5(path).then(() => true).catch(() => false);
|
|
775
|
+
var MiB = 1024 * 1024;
|
|
776
|
+
async function validateRuntimePackage(root, options = {}) {
|
|
777
|
+
if (basename2(root).startsWith("."))
|
|
778
|
+
throw new Error("Runtime package path is not valid.");
|
|
779
|
+
const rootInfo = await lstat3(root).catch(() => {
|
|
780
|
+
return;
|
|
781
|
+
});
|
|
782
|
+
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink())
|
|
783
|
+
throw new Error("A runtime .slop must be a directory, not a symlink.");
|
|
784
|
+
const manifest = await loadManifest(root);
|
|
785
|
+
await requireUTF8File(join6(root, "app.html"), "app.html");
|
|
786
|
+
await rejectSymlinks(root);
|
|
787
|
+
const allowed = new Set(["manifest.json", "app.html", "data.schema.json", "assets", "stores", "QuickLook", ".agents", "Icon\r"]);
|
|
788
|
+
for (const entry of await readdir3(root))
|
|
789
|
+
if (!allowed.has(entry))
|
|
790
|
+
throw new Error(`Runtime packages cannot contain ${entry}.`);
|
|
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;
|
|
841
|
+
try {
|
|
842
|
+
value = JSON.parse(decoder3.decode(await readFile6(json)));
|
|
843
|
+
} catch (error) {
|
|
844
|
+
throw new Error(`stores/data.json must be valid UTF-8 JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
845
|
+
}
|
|
846
|
+
if (schema) {
|
|
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)}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const sqlite = join6(stores, "data.sqlite");
|
|
859
|
+
for (const sidecar of ["data.sqlite-wal", "data.sqlite-shm"]) {
|
|
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.");
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const info = await lstat3(directory);
|
|
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));
|
|
956
|
+
}
|
|
957
|
+
async function requireUTF8File(path, label) {
|
|
958
|
+
const info = await lstat3(path).catch(() => {
|
|
959
|
+
return;
|
|
960
|
+
});
|
|
961
|
+
if (!info?.isFile() || info.isSymbolicLink())
|
|
962
|
+
throw new Error(`${label} must be a regular file.`);
|
|
963
|
+
try {
|
|
964
|
+
decoder3.decode(await readFile6(path));
|
|
965
|
+
} catch {
|
|
966
|
+
throw new Error(`${label} must be UTF-8.`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
async function rejectSymlinks(root) {
|
|
970
|
+
for (const path of await walk2(root))
|
|
971
|
+
if ((await lstat3(path)).isSymbolicLink())
|
|
972
|
+
throw new Error(`Runtime packages cannot contain symlinks: ${relative2(root, path)}.`);
|
|
973
|
+
}
|
|
974
|
+
async function walk2(root, directory = root) {
|
|
975
|
+
const result = [];
|
|
976
|
+
for (const entry of await readdir3(directory, { withFileTypes: true })) {
|
|
977
|
+
const path = join6(directory, entry.name);
|
|
978
|
+
result.push(path);
|
|
979
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
980
|
+
result.push(...await walk2(root, path));
|
|
981
|
+
}
|
|
982
|
+
return result.sort((left, right) => relative2(root, left).split(sep2).join("/").localeCompare(relative2(root, right).split(sep2).join("/")));
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/install.ts
|
|
986
|
+
var exists6 = async (path) => stat6(path).then(() => true).catch(() => false);
|
|
987
|
+
async function installTemplate(input, options = {}) {
|
|
988
|
+
const source = resolve4(input);
|
|
989
|
+
const runtime = await isRuntimePackage(source) ? source : (await buildSlop(source)).directory;
|
|
990
|
+
const manifest = await loadManifest(runtime);
|
|
991
|
+
await validateTemplatePackage(runtime);
|
|
992
|
+
const templatesRoot = resolve4(options.templatesRoot ?? process.env.HITSLOP_TEMPLATES_ROOT ?? join7(homedir2(), ".hitslop", "templates"));
|
|
993
|
+
const target = join7(templatesRoot, `${manifest.slug}.slop`);
|
|
994
|
+
const replaced = await exists6(target);
|
|
995
|
+
if (replaced && !options.force) {
|
|
996
|
+
if (!options.confirmOverwrite)
|
|
997
|
+
throw new Error(`Template ${manifest.slug} is already installed. Pass --force to replace it.`);
|
|
998
|
+
if (!await options.confirmOverwrite(target))
|
|
999
|
+
throw new Error("Install cancelled.");
|
|
1000
|
+
}
|
|
1001
|
+
await mkdir4(templatesRoot, { recursive: true });
|
|
1002
|
+
const staging = join7(templatesRoot, `.${manifest.slug}.${randomUUID()}.installing.slop`);
|
|
1003
|
+
const backup = join7(templatesRoot, `.${manifest.slug}.${randomUUID()}.backup.slop`);
|
|
1004
|
+
try {
|
|
1005
|
+
await cp2(runtime, staging, { recursive: true, errorOnExist: true });
|
|
1006
|
+
const quickLook = join7(staging, "QuickLook");
|
|
1007
|
+
const preview = join7(quickLook, "Preview.png");
|
|
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);
|
|
1028
|
+
if (replaced) {
|
|
1029
|
+
await makePackageWritable(target);
|
|
1030
|
+
await rename(target, backup);
|
|
1031
|
+
}
|
|
1032
|
+
try {
|
|
1033
|
+
await rename(staging, target);
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
if (replaced && await exists6(backup))
|
|
1036
|
+
await rename(backup, target);
|
|
1037
|
+
throw error;
|
|
1038
|
+
}
|
|
1039
|
+
await makePackageImmutable(target);
|
|
1040
|
+
await rm2(backup, { recursive: true, force: true });
|
|
1041
|
+
return { directory: target, manifest, replaced };
|
|
1042
|
+
} finally {
|
|
1043
|
+
await rm2(staging, { recursive: true, force: true });
|
|
1044
|
+
if (await exists6(backup) && !await exists6(target)) {
|
|
1045
|
+
await rename(backup, target);
|
|
1046
|
+
await makePackageImmutable(target).catch(() => {});
|
|
1047
|
+
} else
|
|
1048
|
+
await rm2(backup, { recursive: true, force: true });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
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
|
+
async function isRuntimePackage(path) {
|
|
1059
|
+
return await exists6(join7(path, "manifest.json")) && await exists6(join7(path, "app.html"));
|
|
1060
|
+
}
|
|
1061
|
+
async function validateTemplatePackage(path, options = {}) {
|
|
1062
|
+
await validateRuntimePackage(path, { template: true, requirePreview: options.requirePreview ?? false });
|
|
1063
|
+
}
|
|
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
|
+
async function makePackageWritable(root) {
|
|
1086
|
+
const walk3 = async (path) => {
|
|
1087
|
+
const info = await lstat4(path);
|
|
1088
|
+
if (info.isSymbolicLink())
|
|
1089
|
+
throw new Error(`Runtime packages cannot contain symlinks: ${path}`);
|
|
1090
|
+
const mode = info.mode & 511;
|
|
1091
|
+
await chmod2(path, mode | (info.isDirectory() ? 448 : 384));
|
|
1092
|
+
if (info.isDirectory())
|
|
1093
|
+
for (const entry of await readdir4(path))
|
|
1094
|
+
await walk3(join7(path, entry));
|
|
1095
|
+
};
|
|
1096
|
+
await walk3(root);
|
|
1097
|
+
}
|
|
1098
|
+
async function makePackageImmutable(root) {
|
|
1099
|
+
const walk3 = async (path) => {
|
|
1100
|
+
const info = await lstat4(path);
|
|
1101
|
+
if (info.isSymbolicLink())
|
|
1102
|
+
throw new Error(`Runtime packages cannot contain symlinks: ${path}`);
|
|
1103
|
+
if (info.isDirectory())
|
|
1104
|
+
for (const entry of await readdir4(path))
|
|
1105
|
+
await walk3(join7(path, entry));
|
|
1106
|
+
await chmod2(path, info.mode & 511 & ~146);
|
|
1107
|
+
};
|
|
1108
|
+
await walk3(root);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// src/publish.ts
|
|
1112
|
+
var blobPart = (bytes) => Uint8Array.from(bytes);
|
|
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://hitslop.app/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
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// src/render.ts
|
|
1161
|
+
async function exportDocument(path, options, native = runNative) {
|
|
1162
|
+
await validateRuntimePackage(path);
|
|
1163
|
+
await native(["export", path, "--format", options.format, "--output", options.output]);
|
|
1164
|
+
}
|
|
1165
|
+
async function screenshotDocument(path, options, native = runNative) {
|
|
1166
|
+
await validateRuntimePackage(path);
|
|
1167
|
+
await native([
|
|
1168
|
+
"screenshot",
|
|
1169
|
+
path,
|
|
1170
|
+
"--target",
|
|
1171
|
+
options.target ?? "preview",
|
|
1172
|
+
"--output",
|
|
1173
|
+
options.output,
|
|
1174
|
+
...options.ifPresent ? ["--if-present"] : []
|
|
1175
|
+
]);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// src/cli.ts
|
|
1179
|
+
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.parse(value.trim().toLowerCase()));
|
|
1181
|
+
async function initMetadata(directory, flags) {
|
|
1182
|
+
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
|
+
return defaults;
|
|
1185
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
1186
|
+
try {
|
|
1187
|
+
const title = (await prompt.question(`Title (${defaults.title}): `)).trim() || defaults.title;
|
|
1188
|
+
const description = (await prompt.question(`Description (${defaults.description}): `)).trim() || defaults.description;
|
|
1189
|
+
const rawCategories = (await prompt.question(`Categories, maximum 2 (${defaults.categories.join(", ")}): `)).trim();
|
|
1190
|
+
const categories = rawCategories ? parseCategories(rawCategories.split(",").filter(Boolean)) : defaults.categories;
|
|
1191
|
+
if (categories.length < 1 || categories.length > 2)
|
|
1192
|
+
throw new Error("Choose one or two categories.");
|
|
1193
|
+
return { title, description, categories };
|
|
1194
|
+
} finally {
|
|
1195
|
+
prompt.close();
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
async function confirmReplacement(target) {
|
|
1199
|
+
if (!process.stdin.isTTY)
|
|
1200
|
+
throw new Error(`Template already exists at ${target}. Pass --force to replace it non-interactively.`);
|
|
1201
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
1202
|
+
try {
|
|
1203
|
+
return ["y", "yes"].includes((await prompt.question(`Replace the installed template at ${target}? [y/N] `)).trim().toLowerCase());
|
|
1204
|
+
} finally {
|
|
1205
|
+
prompt.close();
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
var app = new Crust("slop").meta({ description: "Build small, self-contained hitSlop apps.", usage: "slop <command>" });
|
|
1209
|
+
app = app.command("init", (command) => command.meta({ description: "Create a Svelte hitSlop project and manifest." }).args([{ name: "directory", type: "path", default: "my-slop" }]).flags({
|
|
1210
|
+
template: { type: "string", description: "Svelte authoring template (svelte or svelte-counter)." },
|
|
1211
|
+
title: { type: "string", description: "Manifest title." },
|
|
1212
|
+
description: { type: "string", description: "Manifest description." },
|
|
1213
|
+
category: { type: "string", multiple: true, description: "Manifest category; pass once or twice." },
|
|
1214
|
+
yes: { type: "boolean", description: "Accept manifest defaults without prompting." }
|
|
1215
|
+
}).run(async ({ args, flags }) => {
|
|
1216
|
+
const metadata = await initMetadata(args.directory, flags);
|
|
1217
|
+
await scaffold(args.directory, { template: flags.template ?? "svelte-counter", ...metadata });
|
|
1218
|
+
console.log(`Created ${args.directory}`);
|
|
1219
|
+
}));
|
|
1220
|
+
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 stat7(join9(args.path, "app.html")).then((value) => value.isFile()).catch(() => false);
|
|
1222
|
+
const manifest = runtime ? await validateRuntimePackage(args.path) : await validateAuthoringProject(args.path);
|
|
1223
|
+
console.log(`valid ${manifest.slug}`);
|
|
1224
|
+
}));
|
|
1225
|
+
app = app.command("dev", (command) => command.meta({ description: "Preview the UI in a browser with disposable fake stores." }).args([{ name: "path", type: "path", default: "." }]).run(({ args }) => runDev(args.path)));
|
|
1226
|
+
app = app.command("build", (command) => command.args([{ name: "path", type: "path", default: "." }]).run(async ({ args }) => {
|
|
1227
|
+
const result = await buildSlop(args.path);
|
|
1228
|
+
console.log(result.directory);
|
|
1229
|
+
}));
|
|
1230
|
+
app = app.command("register", (command) => command.meta({ description: "Build and register a template in the local hitSlop catalog." }).args([{ name: "path", type: "path", default: "." }]).flags({
|
|
1231
|
+
force: { type: "boolean", description: "Replace an existing local template without prompting." },
|
|
1232
|
+
preview: { type: "path", description: "Use this PNG instead of capturing a fresh native preview." },
|
|
1233
|
+
icon: { type: "path", description: "Use this 512x512 PNG as the catalog and Finder icon." }
|
|
1234
|
+
}).run(async ({ args, flags }) => {
|
|
1235
|
+
const result = await installTemplate(args.path, { force: flags.force ?? false, ...flags.preview ? { preview: flags.preview } : {}, ...flags.icon ? { icon: flags.icon } : {}, confirmOverwrite: confirmReplacement });
|
|
1236
|
+
console.log(`${result.replaced ? "Updated" : "Installed"} ${result.manifest.title} at ${result.directory}`);
|
|
1237
|
+
}));
|
|
1238
|
+
app = app.command("publish", (command) => command.args([{ name: "path", type: "path", default: "." }]).flags({ registry: { type: "string", description: "Catalog publish endpoint. Defaults to https://hitslop.app/api/publish. Set HITSLOP_REGISTRY_URL=http://localhost:3000/api/publish for a local catalog." }, preview: { type: "path" }, icon: { type: "path", description: "Use this 512x512 PNG as the catalog and Finder icon." } }).run(async ({ args, flags }) => {
|
|
1239
|
+
console.log(await publishSlop(args.path, { ...flags.registry ? { registry: flags.registry } : {}, ...flags.preview ? { preview: flags.preview } : {}, ...flags.icon ? { icon: flags.icon } : {} }));
|
|
1240
|
+
}));
|
|
1241
|
+
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({
|
|
1242
|
+
format: { type: "string", description: "Required output format: png or pdf." },
|
|
1243
|
+
output: { type: "path", description: "Required destination path." }
|
|
1244
|
+
}).run(async ({ args, flags }) => {
|
|
1245
|
+
if (flags.format !== "png" && flags.format !== "pdf")
|
|
1246
|
+
throw new Error("--format must be png or pdf.");
|
|
1247
|
+
if (!flags.output)
|
|
1248
|
+
throw new Error("--output is required.");
|
|
1249
|
+
await exportDocument(args.path, { format: flags.format, output: flags.output });
|
|
1250
|
+
}));
|
|
1251
|
+
app = app.command("screenshot", (command) => command.meta({ description: "Capture a built .slop preview or icon render target." }).args([{ name: "path", type: "path", default: "." }]).flags({
|
|
1252
|
+
target: { type: "string", description: "Render target: preview (default) or icon." },
|
|
1253
|
+
output: { type: "path", description: "Required destination path." },
|
|
1254
|
+
"if-present": { type: "boolean", description: "Succeed without output when the target is absent." }
|
|
1255
|
+
}).run(async ({ args, flags }) => {
|
|
1256
|
+
const target = flags.target ?? "preview";
|
|
1257
|
+
if (target !== "preview" && target !== "icon")
|
|
1258
|
+
throw new Error("--target must be preview or icon.");
|
|
1259
|
+
if (!flags.output)
|
|
1260
|
+
throw new Error("--output is required.");
|
|
1261
|
+
await screenshotDocument(args.path, { target, output: flags.output, ifPresent: flags["if-present"] ?? false });
|
|
1262
|
+
}));
|
|
1263
|
+
async function secret(prompt) {
|
|
1264
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1265
|
+
throw new Error("A TTY is required for identity passphrases.");
|
|
1266
|
+
process.stdout.write(prompt);
|
|
1267
|
+
process.stdin.setRawMode(true);
|
|
1268
|
+
process.stdin.resume();
|
|
1269
|
+
return new Promise((resolve6, reject) => {
|
|
1270
|
+
let value = "";
|
|
1271
|
+
const onData = (chunk) => {
|
|
1272
|
+
for (const byte of chunk) {
|
|
1273
|
+
if (byte === 3) {
|
|
1274
|
+
cleanup();
|
|
1275
|
+
reject(new Error("Cancelled."));
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (byte === 13 || byte === 10) {
|
|
1279
|
+
cleanup();
|
|
1280
|
+
process.stdout.write(`
|
|
1281
|
+
`);
|
|
1282
|
+
resolve6(value);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (byte === 127 || byte === 8) {
|
|
1286
|
+
value = value.slice(0, -1);
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
value += String.fromCharCode(byte);
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
const cleanup = () => {
|
|
1293
|
+
process.stdin.off("data", onData);
|
|
1294
|
+
process.stdin.setRawMode(false);
|
|
1295
|
+
process.stdin.pause();
|
|
1296
|
+
};
|
|
1297
|
+
process.stdin.on("data", onData);
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
async function runIdentity(arguments_) {
|
|
1301
|
+
if (arguments_[0] !== "identity")
|
|
1302
|
+
return false;
|
|
1303
|
+
const action = arguments_[1] ?? "show";
|
|
1304
|
+
if (action === "show") {
|
|
1305
|
+
const identity = await getIdentity();
|
|
1306
|
+
console.log(`${identity.keyId} ${identity.displayName}`);
|
|
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}`);
|
|
1315
|
+
return true;
|
|
1316
|
+
}
|
|
1317
|
+
if (action === "export") {
|
|
1318
|
+
const path = arguments_[2];
|
|
1319
|
+
if (!path)
|
|
1320
|
+
throw new Error("Usage: slop identity export <file>");
|
|
1321
|
+
const passphrase = await secret("Export passphrase: ");
|
|
1322
|
+
const confirmation = await secret("Confirm passphrase: ");
|
|
1323
|
+
if (passphrase !== confirmation)
|
|
1324
|
+
throw new Error("Passphrases do not match.");
|
|
1325
|
+
await exportIdentity(path, passphrase);
|
|
1326
|
+
console.log(`Exported publisher identity to ${path}`);
|
|
1327
|
+
return true;
|
|
1328
|
+
}
|
|
1329
|
+
if (action === "import") {
|
|
1330
|
+
const path = arguments_[2];
|
|
1331
|
+
if (!path)
|
|
1332
|
+
throw new Error("Usage: slop identity import <file> [--force]");
|
|
1333
|
+
const identity = await importIdentity(path, await secret("Import passphrase: "), arguments_.includes("--force"));
|
|
1334
|
+
console.log(`${identity.keyId} ${identity.displayName}`);
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
throw new Error(`Unknown identity command: ${action}`);
|
|
1338
|
+
}
|
|
1339
|
+
if (!await runIdentity(process.argv.slice(2)))
|
|
1340
|
+
await app.execute();
|