@deckbuilderkit/build 0.0.0-stage → 0.1.1
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/package.json +26 -4
- package/src/add-view.ts +105 -0
- package/src/app.ts +41 -0
- package/src/build.ts +123 -0
- package/src/cli.ts +135 -0
- package/src/dev.ts +42 -0
- package/src/icon.ts +26 -0
- package/src/keys.ts +46 -0
- package/src/new.ts +58 -0
- package/src/release.ts +182 -0
- package/template/app.ts +10 -0
- package/template/package.json +31 -0
- package/template/server/main.ts +95 -0
- package/template/server/routes.ts +10 -0
- package/template/tsconfig.json +5 -0
- package/template/vite.config.ts +20 -0
- package/template/web/index.html +13 -0
- package/template/web/public/favicon.svg +10 -0
- package/template/web/src/App.tsx +187 -0
- package/template/web/src/api.ts +38 -0
- package/template/web/src/i18n/en.ts +89 -0
- package/template/web/src/i18n/index.ts +8 -0
- package/template/web/src/i18n/pt-BR.ts +91 -0
- package/template/web/src/main.tsx +11 -0
- package/template/web/src/screens/Home.tsx +49 -0
- package/template/web/src/screens/SettingsModal.tsx +541 -0
- package/template/web/src/settings.ts +33 -0
- package/template/web/tsconfig.json +9 -0
- package/view-template/README.md +10 -0
- package/view-template/package.json +25 -0
- package/view-template/src/i18n.ts +14 -0
- package/view-template/src/server.ts +9 -0
- package/view-template/src/styles.css +1 -0
- package/view-template/src/view.tsx +22 -0
- package/view-template/src/web.ts +2 -0
- package/view-template/tsconfig.json +5 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deckbuilderkit/build",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/adrianogpena/deckbuilder.git",
|
|
8
|
+
"directory": "packages/build"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"template",
|
|
16
|
+
"view-template",
|
|
17
|
+
"!src/**/*.test.ts"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"deckbuilder": "src/cli.ts"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@deckbuilderkit/platform": "0.1.1",
|
|
25
|
+
"@deckbuilderkit/server-core": "0.1.1",
|
|
26
|
+
"sharp": "^0.35.5"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/add-view.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// `deckbuilder add view <id>`: scaffolds packages/view-<id> from view-template and registers it in the gallery.
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const TEMPLATE = join(import.meta.dir, '..', 'view-template');
|
|
6
|
+
const ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
7
|
+
|
|
8
|
+
export interface ViewNames {
|
|
9
|
+
id: string;
|
|
10
|
+
/** PascalCase, e.g. `MyView`. */
|
|
11
|
+
name: string;
|
|
12
|
+
/** camelCase, e.g. `myView`. */
|
|
13
|
+
camel: string;
|
|
14
|
+
/** Title words, e.g. `My View`. */
|
|
15
|
+
title: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateViewId(id: string, root: string): string | null {
|
|
19
|
+
if (!ID.test(id)) return `View id "${id}" must be lowercase kebab-case (letters, digits and single dashes, starting with a letter).`;
|
|
20
|
+
if (existsSync(join(root, 'packages', `view-${id}`))) return `packages/view-${id} already exists.`;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function viewNames(id: string): ViewNames {
|
|
25
|
+
const words = id.split('-');
|
|
26
|
+
const cap = (w: string) => w[0]!.toUpperCase() + w.slice(1);
|
|
27
|
+
const name = words.map(cap).join('');
|
|
28
|
+
return { id, name, camel: name[0]!.toLowerCase() + name.slice(1), title: words.map(cap).join(' ') };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fillViewPlaceholders(text: string, n: ViewNames): string {
|
|
32
|
+
return text.replaceAll('__VIEW_ID__', n.id).replaceAll('__VIEW_NAME__', n.name).replaceAll('__VIEW_CAMEL__', n.camel).replaceAll('__VIEW_TITLE__', n.title);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function copyFilled(from: string, to: string, n: ViewNames): void {
|
|
36
|
+
mkdirSync(to, { recursive: true });
|
|
37
|
+
for (const entry of readdirSync(from)) {
|
|
38
|
+
if (entry === 'node_modules') continue;
|
|
39
|
+
const src = join(from, entry);
|
|
40
|
+
if (statSync(src).isDirectory()) copyFilled(src, join(to, entry), n);
|
|
41
|
+
else writeFileSync(join(to, entry), fillViewPlaceholders(readFileSync(src, 'utf8'), n));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function edit(file: string, change: (text: string) => string): void {
|
|
46
|
+
const text = readFileSync(file, 'utf8');
|
|
47
|
+
const crlf = text.includes('\r\n');
|
|
48
|
+
const out = change(text.replaceAll('\r\n', '\n'));
|
|
49
|
+
writeFileSync(file, crlf ? out.replaceAll('\n', '\r\n') : out);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Inserts `line` after the last line matching `pattern`. */
|
|
53
|
+
function insertAfterLast(text: string, pattern: RegExp, line: string, file: string): string {
|
|
54
|
+
const lines = text.split('\n');
|
|
55
|
+
const at = lines.map((l) => pattern.test(l)).lastIndexOf(true);
|
|
56
|
+
if (at < 0) throw new Error(`Cannot register in ${file}: no line matches ${pattern}.`);
|
|
57
|
+
lines.splice(at + 1, 0, line);
|
|
58
|
+
return lines.join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AddViewOptions {
|
|
62
|
+
id: string;
|
|
63
|
+
/** Workspace root. */
|
|
64
|
+
root: string;
|
|
65
|
+
/** Template folder; defaults to the one shipped with the build package. */
|
|
66
|
+
template?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Creates `packages/view-<id>` and registers it in the gallery; returns the package path. */
|
|
70
|
+
export function addView({ id, root, template = TEMPLATE }: AddViewOptions): string {
|
|
71
|
+
const error = validateViewId(id, root);
|
|
72
|
+
if (error) throw new Error(error);
|
|
73
|
+
const n = viewNames(id);
|
|
74
|
+
const web = join(root, 'apps', 'gallery', 'web', 'src', 'views.ts');
|
|
75
|
+
const server = join(root, 'apps', 'gallery', 'server', 'views.ts');
|
|
76
|
+
const pkgJson = join(root, 'apps', 'gallery', 'package.json');
|
|
77
|
+
for (const f of [web, server, pkgJson]) if (!existsSync(f)) throw new Error(`${f} not found; run from the DeckBuilder repo.`);
|
|
78
|
+
|
|
79
|
+
const target = join(root, 'packages', `view-${id}`);
|
|
80
|
+
copyFilled(template, target, n);
|
|
81
|
+
|
|
82
|
+
edit(web, (text) => {
|
|
83
|
+
const orders = [...text.matchAll(/order: (\d+)/g)].map((m) => Number(m[1]));
|
|
84
|
+
const order = Math.max(0, ...orders) + 10;
|
|
85
|
+
const withImport = insertAfterLast(text, /^import .* from '@deckbuilderkit\/view-[\w-]+\/web';?$/, `import { ${n.camel}View } from '@deckbuilderkit/view-${id}/web';`, web);
|
|
86
|
+
const marker = /\],(\r?\n\s*\{ fallback)/;
|
|
87
|
+
if (!marker.test(withImport)) throw new Error(`Cannot register in ${web}: createViews list not found.`);
|
|
88
|
+
return withImport.replace(marker, `, ${n.camel}View({ group: 'nav.section.main', order: ${order} })],$1`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
edit(server, (text) => {
|
|
92
|
+
const withImport = insertAfterLast(text, /^import .* from '@deckbuilderkit\/view-[\w-]+\/server';?$/, `import { ${n.camel}ServerView } from '@deckbuilderkit/view-${id}/server';`, server);
|
|
93
|
+
const marker = /(serverViews: ServerViewDef\[\] = \[[\s\S]*?\n)\];/;
|
|
94
|
+
if (!marker.test(withImport)) throw new Error(`Cannot register in ${server}: serverViews list not found.`);
|
|
95
|
+
return withImport.replace(marker, `$1 ${n.camel}ServerView(),\n];`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
edit(pkgJson, (text) => {
|
|
99
|
+
const pkg = JSON.parse(text) as { dependencies?: Record<string, string> };
|
|
100
|
+
const deps = { ...pkg.dependencies, [`@deckbuilderkit/view-${id}`]: 'workspace:*' };
|
|
101
|
+
pkg.dependencies = Object.fromEntries(Object.entries(deps).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
102
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
103
|
+
});
|
|
104
|
+
return target;
|
|
105
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An app folder, by convention:
|
|
6
|
+
* app.ts exports APP ({ name, id })
|
|
7
|
+
* server/main.ts server entry (calls runApp)
|
|
8
|
+
* web/ Vite root; web/public/favicon.svg is also the exe icon
|
|
9
|
+
* package.json version
|
|
10
|
+
*/
|
|
11
|
+
export interface AppDir {
|
|
12
|
+
dir: string;
|
|
13
|
+
name: string;
|
|
14
|
+
id: string;
|
|
15
|
+
version: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadApp(dir: string): Promise<AppDir> {
|
|
19
|
+
for (const file of ['app.ts', 'server/main.ts', 'package.json']) {
|
|
20
|
+
if (!existsSync(join(dir, file))) throw new Error(`${dir} is not an app folder: ${file} is missing`);
|
|
21
|
+
}
|
|
22
|
+
const { APP } = (await import(join(dir, 'app.ts'))) as { APP: { name: string; id: string } };
|
|
23
|
+
const { version } = (await Bun.file(join(dir, 'package.json')).json()) as { version: string };
|
|
24
|
+
return { dir, name: APP.name, id: APP.id, version };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The folder whose package.json declares `workspaces` (the repo root), or `dir` itself. */
|
|
28
|
+
export async function workspaceRoot(dir: string): Promise<string> {
|
|
29
|
+
for (let d = dir; ; d = dirname(d)) {
|
|
30
|
+
const pkg = join(d, 'package.json');
|
|
31
|
+
if (existsSync(pkg) && 'workspaces' in ((await Bun.file(pkg).json()) as object)) return d;
|
|
32
|
+
if (dirname(d) === d) return dir;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runs a command in the app folder with inherited output; throws when it fails. */
|
|
37
|
+
export function run(app: AppDir, cmd: string[]): void {
|
|
38
|
+
console.log(`> ${cmd.join(' ')}`);
|
|
39
|
+
const r = Bun.spawnSync(cmd, { cwd: app.dir, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
40
|
+
if (r.exitCode !== 0) throw new Error(`failed: ${cmd.join(' ')}`);
|
|
41
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { platformLabel } from '@deckbuilderkit/server-core';
|
|
4
|
+
import { type AppDir, run } from './app.ts';
|
|
5
|
+
import { writeIco } from './icon.ts';
|
|
6
|
+
import { PUBLIC_KEY_FILE } from './keys.ts';
|
|
7
|
+
|
|
8
|
+
const TARGETS = ['windows-x64', 'linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64'] as const;
|
|
9
|
+
export type Target = (typeof TARGETS)[number];
|
|
10
|
+
|
|
11
|
+
/** `--target` value → targets; none means this machine. */
|
|
12
|
+
export function parseTargets(value: string | undefined): Target[] {
|
|
13
|
+
if (!value) {
|
|
14
|
+
const os = process.platform === 'win32' ? 'windows' : process.platform;
|
|
15
|
+
return [`${os}-${process.arch}` as Target];
|
|
16
|
+
}
|
|
17
|
+
if (value === 'all') return [...TARGETS];
|
|
18
|
+
const list = value.split(',').map((t) => t.trim());
|
|
19
|
+
for (const t of list) if (!(TARGETS as readonly string[]).includes(t)) throw new Error(`unknown target ${t}; use ${TARGETS.join(', ')}`);
|
|
20
|
+
return list as Target[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A file-name-safe form of an app name (the update manifest only accepts `[\w.-]`); `fallback` when nothing usable is left. */
|
|
24
|
+
export function fileSlug(name: string, fallback = 'app'): string {
|
|
25
|
+
const slug = name
|
|
26
|
+
.replace(/\s+/g, '-')
|
|
27
|
+
.replace(/[^\w.-]/g, '')
|
|
28
|
+
.replace(/([.-])\1+/g, '$1');
|
|
29
|
+
return !slug || slug.startsWith('.') ? fallback : slug;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `<slug>-<version>-`: what every release file name of the app starts with. */
|
|
33
|
+
export function releasePrefix(app: AppDir): string {
|
|
34
|
+
return `${fileSlug(app.name, app.id)}-${app.version}-`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildWeb(app: AppDir): void {
|
|
38
|
+
run(app, ['bun', 'x', 'vite', 'build', '--logLevel', 'warn']);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function filesUnder(dir: string): string[] {
|
|
42
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
43
|
+
.filter((e) => e.isFile())
|
|
44
|
+
.map((e) => relative(dir, join(e.parentPath, e.name)).replaceAll('\\', '/'))
|
|
45
|
+
.sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Writes the exe's entrypoints to `.build/`: `entry.ts` imports every file of dist/web
|
|
50
|
+
* `with { type: 'file' }` so `bun build --compile` puts them in the exe, registers them, then
|
|
51
|
+
* runs server/main.ts; `window-worker.ts` is the native window worker, `archive-worker.ts` the 7z worker.
|
|
52
|
+
*/
|
|
53
|
+
function writeEntries(app: AppDir): string {
|
|
54
|
+
const out = join(app.dir, '.build');
|
|
55
|
+
const webDist = join(app.dir, 'dist/web');
|
|
56
|
+
const files = filesUnder(webDist);
|
|
57
|
+
if (!files.includes('index.html')) throw new Error('dist/web/index.html missing; run build:web');
|
|
58
|
+
mkdirSync(out, { recursive: true });
|
|
59
|
+
|
|
60
|
+
const keyFile = join(app.dir, PUBLIC_KEY_FILE);
|
|
61
|
+
const key = existsSync(keyFile) ? readFileSync(keyFile, 'utf8').trim() : '';
|
|
62
|
+
const imports = files.map((path, i) => `import f${i} from ${JSON.stringify(`../dist/web/${path}`)} with { type: 'file' };`);
|
|
63
|
+
const map = files.map((path, i) => ` ${JSON.stringify(path)}: f${i},`);
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(out, 'assets.ts'),
|
|
66
|
+
[
|
|
67
|
+
'// Generated by `deckbuilder build`; do not edit.',
|
|
68
|
+
`import { setEmbeddedWebAssets${key ? ', setEmbeddedUpdateKey' : ''} } from '@deckbuilderkit/server-core';`,
|
|
69
|
+
...imports,
|
|
70
|
+
`setEmbeddedWebAssets({\n${map.join('\n')}\n});`,
|
|
71
|
+
// The updater then trusts only manifests signed with this key (`deckbuilder keygen`).
|
|
72
|
+
...(key ? [`setEmbeddedUpdateKey(${JSON.stringify(key)});`] : []),
|
|
73
|
+
'',
|
|
74
|
+
].join('\n'),
|
|
75
|
+
);
|
|
76
|
+
// ES modules run in import order: the assets are registered before main.ts calls runApp.
|
|
77
|
+
writeFileSync(
|
|
78
|
+
join(out, 'entry.ts'),
|
|
79
|
+
"// Generated by `deckbuilder build`; do not edit.\nimport './assets.ts';\nimport '../server/main.ts';\n",
|
|
80
|
+
);
|
|
81
|
+
// Inside the exe, the shell's `new URL('./window-worker.ts', import.meta.url)` resolves next to
|
|
82
|
+
// the bundled entry, so the worker entrypoint must sit beside entry.ts.
|
|
83
|
+
writeFileSync(
|
|
84
|
+
join(out, 'window-worker.ts'),
|
|
85
|
+
"// Generated by `deckbuilder build`; do not edit.\nimport '@deckbuilderkit/shell/window-worker';\n",
|
|
86
|
+
);
|
|
87
|
+
// Same for server-mods' 7z worker (archive extract/compress).
|
|
88
|
+
writeFileSync(
|
|
89
|
+
join(out, 'archive-worker.ts'),
|
|
90
|
+
"// Generated by `deckbuilder build`; do not edit.\nimport '@deckbuilderkit/server-mods/archive-worker';\n",
|
|
91
|
+
);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Compiles one self-contained exe for `target` into release/ and returns its path. */
|
|
96
|
+
export async function buildExe(app: AppDir, target: Target): Promise<string> {
|
|
97
|
+
const dir = writeEntries(app);
|
|
98
|
+
const [os, arch] = target.split('-') as [string, string];
|
|
99
|
+
const windows = os === 'windows';
|
|
100
|
+
const out = join(app.dir, 'release', `${releasePrefix(app)}${platformLabel(os, arch)}${windows ? '.exe' : ''}`);
|
|
101
|
+
try {
|
|
102
|
+
rmSync(out, { force: true });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Windows locks a running exe; Bun reports that as EFAULT or EBUSY.
|
|
105
|
+
throw new Error(`Can't replace ${relative(app.dir, out)}: quit the running app first. (${(err as Error).message})`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const cmd = ['bun', 'build', join(dir, 'entry.ts'), join(dir, 'window-worker.ts'), join(dir, 'archive-worker.ts'), '--compile', '--minify', `--target=bun-${target}`, `--outfile=${out}`];
|
|
109
|
+
if (windows) {
|
|
110
|
+
const ico = join(app.dir, '.build', 'icon.ico');
|
|
111
|
+
await writeIco(join(app.dir, 'web/public/favicon.svg'), ico);
|
|
112
|
+
cmd.push(
|
|
113
|
+
'--windows-hide-console',
|
|
114
|
+
`--windows-icon=${ico}`,
|
|
115
|
+
`--windows-title=${app.name}`,
|
|
116
|
+
`--windows-description=${app.name}`,
|
|
117
|
+
`--windows-version=${app.version.split('-')[0]}.0`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
run(app, cmd);
|
|
121
|
+
console.log(`built ${relative(app.dir, out)}`);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// `deckbuilder <command>`, run from an app folder (apps/<name>) through its package.json scripts.
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import type { VersionBump } from '@deckbuilderkit/server-core';
|
|
5
|
+
import { buildExe, buildWeb, parseTargets } from './build.ts';
|
|
6
|
+
import { dev } from './dev.ts';
|
|
7
|
+
import { addView } from './add-view.ts';
|
|
8
|
+
import { loadApp, workspaceRoot } from './app.ts';
|
|
9
|
+
import { keygen } from './keys.ts';
|
|
10
|
+
import { createApp } from './new.ts';
|
|
11
|
+
import { BUMPS, release, writeManifest } from './release.ts';
|
|
12
|
+
|
|
13
|
+
const [command, ...args] = process.argv.slice(2);
|
|
14
|
+
|
|
15
|
+
async function main(): Promise<void> {
|
|
16
|
+
// Only `new` runs outside an app folder.
|
|
17
|
+
if (command === 'new') {
|
|
18
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { name: { type: 'string' }, id: { type: 'string' } } });
|
|
19
|
+
const [dir] = positionals;
|
|
20
|
+
if (!dir) {
|
|
21
|
+
console.error('Usage: deckbuilder new <dir> [--name <Name>] [--id <id>]');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const target = await createApp({ dir, name: values.name, id: values.id }, process.cwd());
|
|
25
|
+
console.log(`Created ${target}
|
|
26
|
+
|
|
27
|
+
Next:
|
|
28
|
+
bun install
|
|
29
|
+
cd apps/${dir}
|
|
30
|
+
bun run dev`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (command === 'add') {
|
|
35
|
+
const [what, id] = args;
|
|
36
|
+
if (what !== 'view' || !id) {
|
|
37
|
+
console.error('Usage: deckbuilder add view <id>');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const target = addView({ id, root: await workspaceRoot(process.cwd()) });
|
|
41
|
+
console.log(`Created ${target} and registered it in apps/gallery
|
|
42
|
+
|
|
43
|
+
Next:
|
|
44
|
+
bun install`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const app = await loadApp(process.cwd());
|
|
49
|
+
|
|
50
|
+
switch (command) {
|
|
51
|
+
case 'dev':
|
|
52
|
+
await dev(app, args);
|
|
53
|
+
break;
|
|
54
|
+
case 'build:web':
|
|
55
|
+
buildWeb(app);
|
|
56
|
+
break;
|
|
57
|
+
case 'build': {
|
|
58
|
+
const { values } = parseArgs({ args, options: { target: { type: 'string' }, 'no-web': { type: 'boolean', default: false } } });
|
|
59
|
+
const targets = parseTargets(values.target);
|
|
60
|
+
if (!values['no-web']) buildWeb(app);
|
|
61
|
+
for (const target of targets) await buildExe(app, target);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case 'release': {
|
|
65
|
+
const { values } = parseArgs({
|
|
66
|
+
args,
|
|
67
|
+
options: {
|
|
68
|
+
to: { type: 'string' },
|
|
69
|
+
bump: { type: 'string', default: 'patch' },
|
|
70
|
+
target: { type: 'string' },
|
|
71
|
+
'dry-run': { type: 'boolean', default: false },
|
|
72
|
+
'no-checks': { type: 'boolean', default: false },
|
|
73
|
+
unsigned: { type: 'boolean', default: false },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
if (!values.to || !BUMPS.includes(values.bump as VersionBump)) {
|
|
77
|
+
console.error('Usage: deckbuilder release --to <folder> [--bump patch|minor|major] [--target <list>] [--dry-run] [--no-checks] [--unsigned]');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
await release(app, {
|
|
81
|
+
to: values.to,
|
|
82
|
+
bump: values.bump as VersionBump,
|
|
83
|
+
targets: parseTargets(values.target),
|
|
84
|
+
dryRun: values['dry-run'],
|
|
85
|
+
checks: !values['no-checks'],
|
|
86
|
+
unsigned: values.unsigned,
|
|
87
|
+
});
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case 'manifest': {
|
|
91
|
+
const { values } = parseArgs({ args, options: { unsigned: { type: 'boolean', default: false } } });
|
|
92
|
+
await writeManifest(app, values.unsigned);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case 'keygen': {
|
|
96
|
+
const { values } = parseArgs({ args, options: { force: { type: 'boolean', default: false } } });
|
|
97
|
+
const { privateKey, publicKey } = keygen({ appId: app.id, appDir: app.dir, force: values.force });
|
|
98
|
+
console.log(`wrote ${privateKey} (private: back it up, never commit it)
|
|
99
|
+
wrote ${publicKey} (public: commit it; the exe embeds it)`);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
console.log(`Usage: deckbuilder <command>
|
|
104
|
+
|
|
105
|
+
new <dir> [--name <Name>] [--id <id>]
|
|
106
|
+
create apps/<dir> from the template (run from the repo root:
|
|
107
|
+
bun run new <dir>); id = URL scheme, data folder ~/.<id>
|
|
108
|
+
add view <id> scaffold packages/view-<id> from the view template and register it
|
|
109
|
+
in the gallery (run from the repo root)
|
|
110
|
+
dev [server flags] server (--dev, watch mode) + Vite dev server; e.g. dev --window, dev --app
|
|
111
|
+
build:web bundle web/ into dist/web
|
|
112
|
+
build [--target <list>] build:web, then one exe per target into release/
|
|
113
|
+
targets: windows-x64, linux-x64, linux-arm64, darwin-x64,
|
|
114
|
+
darwin-arm64, or "all" (default: this machine)
|
|
115
|
+
[--no-web] reuse the existing dist/web
|
|
116
|
+
release --to <folder> bump the version, run typecheck + tests, build, copy the exes and
|
|
117
|
+
latest.json to <folder>/<app name>/ (the updater's release folder),
|
|
118
|
+
then commit and tag <app folder>-v<version> locally (never pushes)
|
|
119
|
+
[--bump patch|minor|major] [--target <list>] [--no-checks]
|
|
120
|
+
[--dry-run] publish only; package.json and git stay as they are
|
|
121
|
+
[--unsigned] skip latest.json.sig (signed builds then refuse the update)
|
|
122
|
+
manifest [--unsigned] write release/latest.json and its .sig for the exes of the current
|
|
123
|
+
version in release/ (what GitHub releases need next to the exes)
|
|
124
|
+
keygen [--force] create the update signing key: private ~/.deckbuilder-keys/<id>.pem,
|
|
125
|
+
public update-key.pub in the app folder (commit it); release and
|
|
126
|
+
manifest sign with it (or env DECKBUILDER_SIGNING_KEY)`);
|
|
127
|
+
process.exit(command ? 1 : 0);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// One error path for every command: the message, no stack.
|
|
132
|
+
main().catch((err: unknown) => {
|
|
133
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
134
|
+
process.exit(1);
|
|
135
|
+
});
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { killTreeSync, waitForUrl } from '@deckbuilderkit/platform';
|
|
2
|
+
import { DEV_PORT, VITE_PORT } from '@deckbuilderkit/server-core/protocol';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { type AppDir, workspaceRoot } from './app.ts';
|
|
5
|
+
|
|
6
|
+
const WAIT_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runs the server in watch mode (`--dev`: port 7420; it prints the URL to open) and the Vite dev
|
|
10
|
+
* server, which proxies /api to it. Vite starts once the server answers, so the first page load
|
|
11
|
+
* doesn't hit a closed port. Ctrl+C stops both process trees (on Windows a plain kill
|
|
12
|
+
* leaves the grandchildren running).
|
|
13
|
+
*/
|
|
14
|
+
export async function dev(app: AppDir, serverArgs: string[]): Promise<void> {
|
|
15
|
+
const children: ReturnType<typeof Bun.spawn>[] = [];
|
|
16
|
+
let stopping = false;
|
|
17
|
+
|
|
18
|
+
function stop(code: number): void {
|
|
19
|
+
if (stopping) return;
|
|
20
|
+
stopping = true;
|
|
21
|
+
for (const child of children) killTreeSync(child.pid);
|
|
22
|
+
process.exit(code);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function start(cmd: string[], cwd = app.dir): void {
|
|
26
|
+
const child = Bun.spawn(cmd, { cwd, stdio: ['inherit', 'inherit', 'inherit'] });
|
|
27
|
+
children.push(child);
|
|
28
|
+
void child.exited.then((code) => stop(code ?? 0));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
process.on('SIGINT', () => stop(0));
|
|
32
|
+
process.on('SIGTERM', () => stop(0));
|
|
33
|
+
|
|
34
|
+
// From the workspace root: `bun --watch` only watches files inside its project folder, and the
|
|
35
|
+
// server's code is mostly in packages/.
|
|
36
|
+
const root = await workspaceRoot(app.dir);
|
|
37
|
+
start(['bun', '--watch', relative(root, join(app.dir, 'server/main.ts')), '--dev', ...serverArgs], root);
|
|
38
|
+
await waitForUrl(`http://127.0.0.1:${DEV_PORT}/`, { timeoutMs: WAIT_MS, until: () => stopping });
|
|
39
|
+
if (stopping) return;
|
|
40
|
+
// Vite would clear the terminal, and with it the server's line with the URL (and launch code) to open.
|
|
41
|
+
start(['bun', 'x', 'vite', '--port', String(VITE_PORT), '--strictPort', '--clearScreen', 'false']);
|
|
42
|
+
}
|
package/src/icon.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
const SIZES = [16, 24, 32, 48, 64, 128, 256];
|
|
4
|
+
|
|
5
|
+
/** Renders an SVG into a Windows .ico of PNG payloads (allowed since Vista), one per size. */
|
|
6
|
+
export async function writeIco(svgPath: string, icoPath: string): Promise<void> {
|
|
7
|
+
const svg = await Bun.file(svgPath).arrayBuffer();
|
|
8
|
+
const images = await Promise.all(
|
|
9
|
+
SIZES.map(async (size) => [size, await sharp(Buffer.from(svg), { density: 384 }).resize(size, size).png().toBuffer()] as const),
|
|
10
|
+
);
|
|
11
|
+
const header = Buffer.alloc(6 + 16 * images.length);
|
|
12
|
+
header.writeUInt16LE(1, 2); // type: icon
|
|
13
|
+
header.writeUInt16LE(images.length, 4);
|
|
14
|
+
let offset = header.length;
|
|
15
|
+
images.forEach(([size, png], i) => {
|
|
16
|
+
const at = 6 + 16 * i;
|
|
17
|
+
header.writeUInt8(size >= 256 ? 0 : size, at); // 0 means 256
|
|
18
|
+
header.writeUInt8(size >= 256 ? 0 : size, at + 1);
|
|
19
|
+
header.writeUInt16LE(1, at + 4); // colour planes
|
|
20
|
+
header.writeUInt16LE(32, at + 6); // bits per pixel
|
|
21
|
+
header.writeUInt32LE(png.length, at + 8);
|
|
22
|
+
header.writeUInt32LE(offset, at + 12);
|
|
23
|
+
offset += png.length;
|
|
24
|
+
});
|
|
25
|
+
await Bun.write(icoPath, Buffer.concat([header, ...images.map(([, png]) => png)]));
|
|
26
|
+
}
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createPrivateKey, generateKeyPairSync, sign } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** Where an app's private signing key lives: `~/.deckbuilder-keys/<app id>.pem`, outside every repo. */
|
|
7
|
+
export const signingKeyPath = (appId: string, home = homedir()): string => join(home, '.deckbuilder-keys', `${appId}.pem`);
|
|
8
|
+
|
|
9
|
+
/** The public key the app embeds: base64 of the 32 raw Ed25519 bytes, committed next to app.ts. */
|
|
10
|
+
export const PUBLIC_KEY_FILE = 'update-key.pub';
|
|
11
|
+
|
|
12
|
+
export interface KeygenOptions {
|
|
13
|
+
appId: string;
|
|
14
|
+
appDir: string;
|
|
15
|
+
home?: string;
|
|
16
|
+
/** Replaces an existing private key; every installed exe then rejects new releases. */
|
|
17
|
+
force?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Creates the app's Ed25519 key pair; returns the paths written. */
|
|
21
|
+
export function keygen({ appId, appDir, home = homedir(), force = false }: KeygenOptions): { privateKey: string; publicKey: string } {
|
|
22
|
+
const privatePath = signingKeyPath(appId, home);
|
|
23
|
+
if (existsSync(privatePath) && !force) {
|
|
24
|
+
throw new Error(`${privatePath} already exists; pass --force to replace it (installed exes then reject new releases).`);
|
|
25
|
+
}
|
|
26
|
+
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
27
|
+
mkdirSync(join(home, '.deckbuilder-keys'), { recursive: true, mode: 0o700 });
|
|
28
|
+
writeFileSync(privatePath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
|
|
29
|
+
const raw = (publicKey.export({ type: 'spki', format: 'der' }) as Buffer).subarray(-32);
|
|
30
|
+
const publicPath = join(appDir, PUBLIC_KEY_FILE);
|
|
31
|
+
writeFileSync(publicPath, `${raw.toString('base64')}\n`);
|
|
32
|
+
return { privateKey: privatePath, publicKey: publicPath };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The PEM private key: env `DECKBUILDER_SIGNING_KEY` (CI), else the key file, else null. */
|
|
36
|
+
export function loadSigningKey(appId: string, home = homedir()): string | null {
|
|
37
|
+
const env = process.env.DECKBUILDER_SIGNING_KEY;
|
|
38
|
+
if (env?.trim()) return env;
|
|
39
|
+
const path = signingKeyPath(appId, home);
|
|
40
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Base64 Ed25519 signature of `bytes`. */
|
|
44
|
+
export function signManifest(bytes: Uint8Array, privateKeyPem: string): string {
|
|
45
|
+
return sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64');
|
|
46
|
+
}
|
package/src/new.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { extname, join } from 'node:path';
|
|
3
|
+
import { isUrlScheme } from '@deckbuilderkit/server-core/protocol';
|
|
4
|
+
import { workspaceRoot } from './app.ts';
|
|
5
|
+
|
|
6
|
+
const TEMPLATE = join(import.meta.dir, '..', 'template');
|
|
7
|
+
const BINARY = new Set(['.png', '.ico', '.woff', '.woff2']);
|
|
8
|
+
|
|
9
|
+
export interface NewAppOptions {
|
|
10
|
+
dir: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NewApp {
|
|
16
|
+
dir: string;
|
|
17
|
+
name: string;
|
|
18
|
+
id: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `my-tool` → `My Tool`. */
|
|
22
|
+
const titleOf = (dir: string): string => dir.split('-').filter(Boolean).map((w) => w[0]!.toUpperCase() + w.slice(1)).join(' ');
|
|
23
|
+
|
|
24
|
+
/** Applies the defaults and returns the problem with the input, if any. */
|
|
25
|
+
export function validateNewApp(opts: NewAppOptions): { app: NewApp } | { error: string } {
|
|
26
|
+
if (!/^[a-z][a-z0-9-]*$/.test(opts.dir)) return { error: `Folder name "${opts.dir}" must be lowercase letters, digits and dashes, starting with a letter.` };
|
|
27
|
+
const id = opts.id ?? opts.dir.replaceAll('-', '');
|
|
28
|
+
if (!isUrlScheme(id)) return { error: `Id "${id}" must be lowercase letters, digits, "+", "." or "-", starting with a letter (it is the URL scheme).` };
|
|
29
|
+
const name = opts.name?.trim() || titleOf(opts.dir);
|
|
30
|
+
if (/['"`/\\\x00-\x1f\x7f]|\.\./.test(name)) return { error: `Name "${name}" must not contain quotes, slashes, ".." or control characters.` };
|
|
31
|
+
return { app: { dir: opts.dir, name, id } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function fillPlaceholders(text: string, app: NewApp): string {
|
|
35
|
+
return text.replaceAll('__APP_NAME__', app.name).replaceAll('__APP_ID__', app.id).replaceAll('__APP_DIR__', app.dir);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function copyFilled(from: string, to: string, app: NewApp): void {
|
|
39
|
+
mkdirSync(to, { recursive: true });
|
|
40
|
+
for (const entry of readdirSync(from)) {
|
|
41
|
+
const src = join(from, entry);
|
|
42
|
+
const dest = join(to, entry);
|
|
43
|
+
if (statSync(src).isDirectory()) copyFilled(src, dest, app);
|
|
44
|
+
else if (BINARY.has(extname(entry).toLowerCase())) cpSync(src, dest);
|
|
45
|
+
else writeFileSync(dest, fillPlaceholders(readFileSync(src, 'utf8'), app));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Creates `apps/<dir>` in the workspace root from the template; returns its path. */
|
|
50
|
+
export async function createApp(opts: NewAppOptions, cwd: string): Promise<string> {
|
|
51
|
+
const checked = validateNewApp(opts);
|
|
52
|
+
if ('error' in checked) throw new Error(checked.error);
|
|
53
|
+
const root = await workspaceRoot(cwd);
|
|
54
|
+
const target = join(root, 'apps', checked.app.dir);
|
|
55
|
+
if (existsSync(target)) throw new Error(`${target} already exists.`);
|
|
56
|
+
copyFilled(TEMPLATE, target, checked.app);
|
|
57
|
+
return target;
|
|
58
|
+
}
|