create-syncular-app 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -27
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +131 -179
- package/dist/constants.d.ts +40 -0
- package/dist/constants.js +46 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/scaffold.d.ts +44 -0
- package/dist/scaffold.js +106 -0
- package/package.json +22 -15
- package/src/cli.ts +174 -0
- package/src/constants.ts +53 -0
- package/src/index.ts +3 -0
- package/src/scaffold.ts +161 -0
- package/template/minimal/README.md +45 -0
- package/template/minimal/gitignore +5 -0
- package/template/minimal/migrations/0001_initial/up.sql +9 -0
- package/template/minimal/package.json +22 -0
- package/template/minimal/src/clients.ts +54 -0
- package/template/minimal/src/make-client.ts +26 -0
- package/template/minimal/src/server.ts +39 -0
- package/template/minimal/src/smoke.test.ts +70 -0
- package/template/minimal/src/syncular.generated.ts +66 -0
- package/template/minimal/syncular.ir.json +66 -0
- package/template/minimal/syncular.json +17 -0
- package/template/minimal/tsconfig.json +16 -0
- package/template/web/README.md +63 -0
- package/template/web/gitignore +5 -0
- package/template/web/migrations/0001_initial/up.sql +11 -0
- package/template/web/package.json +22 -0
- package/template/web/src/frontend/index.html +37 -0
- package/template/web/src/frontend/main.ts +191 -0
- package/template/web/src/frontend/worker.ts +9 -0
- package/template/web/src/server.ts +183 -0
- package/template/web/src/smoke.test.ts +91 -0
- package/template/web/src/syncular.generated.ts +74 -0
- package/template/web/syncular.ir.json +76 -0
- package/template/web/syncular.json +17 -0
- package/template/web/tsconfig.json +16 -0
- package/template/README.md +0 -122
- package/template/_gitignore +0 -5
- package/template/generated/kotlin/SyncularApp.kt +0 -1005
- package/template/generated/rust/diesel_tables.rs +0 -142
- package/template/generated/rust/migrations.rs +0 -32
- package/template/generated/rust/schema.rs +0 -15
- package/template/generated/rust/syncular.rs +0 -926
- package/template/generated/swift/SyncularApp.swift +0 -1191
- package/template/generated/syncular.codegen.json +0 -19
- package/template/index.html +0 -13
- package/template/migrations/0001_initial/down.sql +0 -1
- package/template/migrations/0001_initial/up.sql +0 -8
- package/template/package.json +0 -33
- package/template/scripts/dev.ts +0 -42
- package/template/src/app.tsx +0 -231
- package/template/src/client/syncular.ts +0 -61
- package/template/src/generated/syncular.generated.ts +0 -769
- package/template/src/generated/syncular.server.generated.ts +0 -512
- package/template/src/main.tsx +0 -5
- package/template/src/server/sync-server.ts +0 -129
- package/template/src/styles.css +0 -233
- package/template/syncular.app.ts +0 -23
- package/template/syncular.schema.json +0 -145
- package/template/tsconfig.json +0 -18
- package/template/vite.config.ts +0 -9
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scaffolder's heart: copy a template tree into a target directory, with
|
|
3
|
+
* dumb, greppable placeholder substitution and a single dependency-range
|
|
4
|
+
* decision. Kept pure of process/argv concerns so tests drive it directly.
|
|
5
|
+
*/
|
|
6
|
+
import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
7
|
+
import { basename, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { PACKAGE_SCOPE, PLACEHOLDER, PUBLISHED_DEPENDENCY_RANGE, } from './constants.js';
|
|
10
|
+
/** The two templates this rung ships. */
|
|
11
|
+
export const TEMPLATES = ['minimal', 'web'];
|
|
12
|
+
export function isTemplateName(value) {
|
|
13
|
+
return TEMPLATES.includes(value);
|
|
14
|
+
}
|
|
15
|
+
/** Absolute path to `template/` inside this package (works for src or dist). */
|
|
16
|
+
export function templatesRoot() {
|
|
17
|
+
return fileURLToPath(new URL('../template', import.meta.url));
|
|
18
|
+
}
|
|
19
|
+
/** Derive a safe npm package name from the target directory basename. */
|
|
20
|
+
export function packageNameFromDirectory(targetDir) {
|
|
21
|
+
const name = basename(resolve(targetDir))
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9-_.~]+/g, '-')
|
|
24
|
+
.replace(/^[-._]+|[-._]+$/g, '');
|
|
25
|
+
return name.length > 0 ? name : `${PACKAGE_SCOPE.slice(1)}-app`;
|
|
26
|
+
}
|
|
27
|
+
function isWorkspacePackage(dependencyName) {
|
|
28
|
+
return dependencyName.startsWith(`${PACKAGE_SCOPE}/`);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Rewrite a template's package.json: set `name`, and resolve the dependency
|
|
32
|
+
* range for every `@<scope>/*` workspace dependency.
|
|
33
|
+
*
|
|
34
|
+
* The local-vs-published question, decided honestly (also documented in the
|
|
35
|
+
* package README):
|
|
36
|
+
* - `local: true` keeps `workspace:*` verbatim — the only ranges that resolve
|
|
37
|
+
* when the scaffolded app sits inside this repo's workspace (the in-tree
|
|
38
|
+
* smoke test path, and `--local` for anyone hacking on the tree).
|
|
39
|
+
* - `local: false` rewrites to {@link PUBLISHED_DEPENDENCY_RANGE}. Today that
|
|
40
|
+
* is *also* `workspace:*` because the v2 packages are unpublished and
|
|
41
|
+
* version-less (TODO 6.3); the CLI warns. It is one constant to flip the day
|
|
42
|
+
* they publish.
|
|
43
|
+
*/
|
|
44
|
+
export function rewriteTemplatePackageJson(source, options) {
|
|
45
|
+
const pkg = JSON.parse(source);
|
|
46
|
+
pkg.name = options.packageName;
|
|
47
|
+
const range = options.local ? 'workspace:*' : PUBLISHED_DEPENDENCY_RANGE;
|
|
48
|
+
for (const section of [pkg.dependencies, pkg.devDependencies]) {
|
|
49
|
+
if (section === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
for (const [name, current] of Object.entries(section)) {
|
|
52
|
+
if (isWorkspacePackage(name) && current.startsWith('workspace:')) {
|
|
53
|
+
section[name] = range;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
58
|
+
}
|
|
59
|
+
/** Replace `__PROJECT_NAME__` everywhere in a text file body. */
|
|
60
|
+
function substitutePlaceholders(body, projectName) {
|
|
61
|
+
return body.replaceAll(PLACEHOLDER.projectName, projectName);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Files whose bodies get `__PROJECT_NAME__` substitution. Everything else is
|
|
65
|
+
* copied byte-for-byte. Deliberately a short allow-list (missing paths are
|
|
66
|
+
* skipped per-template) — substitution stays dumb and auditable rather than
|
|
67
|
+
* scanning every file. Paths are relative + POSIX-joined below.
|
|
68
|
+
*/
|
|
69
|
+
const SUBSTITUTE_FILES = ['README.md', 'src/frontend/index.html'];
|
|
70
|
+
function directoryIsEmpty(path) {
|
|
71
|
+
return readdirSync(path).length === 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Copy `template/<template>` into `targetDir` and finalize it: restore the
|
|
75
|
+
* `.gitignore` name (npm strips real dotfiles from tarballs, so templates ship
|
|
76
|
+
* `gitignore`), rewrite package.json, and substitute placeholders.
|
|
77
|
+
*/
|
|
78
|
+
export function scaffoldApp(options) {
|
|
79
|
+
const targetDir = resolve(options.targetDir);
|
|
80
|
+
const templateDir = join(templatesRoot(), options.template);
|
|
81
|
+
const local = options.local ?? false;
|
|
82
|
+
if (!existsSync(templateDir)) {
|
|
83
|
+
throw new Error(`unknown template directory: ${templateDir}`);
|
|
84
|
+
}
|
|
85
|
+
if (existsSync(targetDir) && !directoryIsEmpty(targetDir)) {
|
|
86
|
+
throw new Error(`target directory is not empty: ${targetDir}`);
|
|
87
|
+
}
|
|
88
|
+
cpSync(templateDir, targetDir, { recursive: true });
|
|
89
|
+
const gitignorePlaceholder = join(targetDir, 'gitignore');
|
|
90
|
+
if (existsSync(gitignorePlaceholder)) {
|
|
91
|
+
renameSync(gitignorePlaceholder, join(targetDir, '.gitignore'));
|
|
92
|
+
}
|
|
93
|
+
const packageName = packageNameFromDirectory(targetDir);
|
|
94
|
+
const packageJsonPath = join(targetDir, 'package.json');
|
|
95
|
+
writeFileSync(packageJsonPath, rewriteTemplatePackageJson(readFileSync(packageJsonPath, 'utf8'), {
|
|
96
|
+
packageName,
|
|
97
|
+
local,
|
|
98
|
+
}));
|
|
99
|
+
for (const relPath of SUBSTITUTE_FILES) {
|
|
100
|
+
const path = join(targetDir, relPath);
|
|
101
|
+
if (existsSync(path)) {
|
|
102
|
+
writeFileSync(path, substitutePlaceholders(readFileSync(path, 'utf8'), packageName));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { targetDir, packageName, template: options.template, local };
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-syncular-app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Scaffold a
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Scaffold a new Syncular app: `create-syncular-app`",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
7
7
|
"homepage": "https://syncular.dev",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "https://github.com/syncular/syncular.git",
|
|
11
|
-
"directory": "packages/create-
|
|
11
|
+
"directory": "packages/create-app"
|
|
12
12
|
},
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/syncular/syncular/issues"
|
|
@@ -18,30 +18,37 @@
|
|
|
18
18
|
"offline-first",
|
|
19
19
|
"realtime",
|
|
20
20
|
"database",
|
|
21
|
-
"typescript"
|
|
22
|
-
"create",
|
|
23
|
-
"scaffold"
|
|
21
|
+
"typescript"
|
|
24
22
|
],
|
|
23
|
+
"type": "module",
|
|
25
24
|
"sideEffects": false,
|
|
26
25
|
"publishConfig": {
|
|
27
26
|
"access": "public"
|
|
28
27
|
},
|
|
29
|
-
"type": "module",
|
|
30
28
|
"bin": {
|
|
31
29
|
"create-syncular-app": "./dist/cli.js"
|
|
32
30
|
},
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"bun": "./src/index.ts",
|
|
34
|
+
"browser": "./src/index.ts",
|
|
35
|
+
"import": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
39
40
|
},
|
|
40
41
|
"files": [
|
|
41
42
|
"dist",
|
|
42
|
-
"
|
|
43
|
+
"src",
|
|
44
|
+
"template",
|
|
45
|
+
"README.md",
|
|
46
|
+
"!src/**/*.test.ts",
|
|
47
|
+
"!src/**/*.test.tsx",
|
|
48
|
+
"!dist/**/*.test.js",
|
|
49
|
+
"!dist/**/*.test.d.ts"
|
|
43
50
|
],
|
|
44
51
|
"devDependencies": {
|
|
45
|
-
"@syncular/
|
|
52
|
+
"@syncular/typegen": "0.2.0"
|
|
46
53
|
}
|
|
47
54
|
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `create-syncular-app [project-name] [--template <minimal|web>] [--local]`
|
|
4
|
+
*
|
|
5
|
+
* Runnable as `bun create syncular-app my-app` or `bunx create-syncular-app
|
|
6
|
+
* my-app`. Prompts for anything a flag did not supply. Scaffolds one of the
|
|
7
|
+
* two templates (see `./scaffold`).
|
|
8
|
+
*/
|
|
9
|
+
import { relative } from 'node:path';
|
|
10
|
+
import { createInterface } from 'node:readline/promises';
|
|
11
|
+
import { CLI_BIN, CREATE_BIN, PRODUCT_NAME } from './constants';
|
|
12
|
+
import {
|
|
13
|
+
isTemplateName,
|
|
14
|
+
scaffoldApp,
|
|
15
|
+
TEMPLATES,
|
|
16
|
+
type TemplateName,
|
|
17
|
+
} from './scaffold';
|
|
18
|
+
|
|
19
|
+
interface ParsedArgs {
|
|
20
|
+
readonly help: boolean;
|
|
21
|
+
readonly local: boolean;
|
|
22
|
+
readonly projectName?: string;
|
|
23
|
+
readonly template?: TemplateName;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function usage(): string {
|
|
27
|
+
return `usage: ${CREATE_BIN} [project-name] [options]
|
|
28
|
+
|
|
29
|
+
Scaffolds a ${PRODUCT_NAME} v2 starter app.
|
|
30
|
+
|
|
31
|
+
templates:
|
|
32
|
+
minimal server + a terminal two-client convergence demo (no browser)
|
|
33
|
+
web Hono server + WebSocket + a single-pane browser todo app
|
|
34
|
+
(worker core on OPFS)
|
|
35
|
+
|
|
36
|
+
options:
|
|
37
|
+
--template <name> one of: ${TEMPLATES.join(', ')} (prompts if omitted)
|
|
38
|
+
--local keep workspace:* deps (scaffolding inside this repo)
|
|
39
|
+
-h, --help show this help
|
|
40
|
+
|
|
41
|
+
examples:
|
|
42
|
+
bun create syncular-app my-app
|
|
43
|
+
bunx ${CREATE_BIN} my-app --template web
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
48
|
+
let help = false;
|
|
49
|
+
let local = false;
|
|
50
|
+
let projectName: string | undefined;
|
|
51
|
+
let template: TemplateName | undefined;
|
|
52
|
+
for (let i = 0; i < argv.length; i++) {
|
|
53
|
+
const arg = argv[i];
|
|
54
|
+
if (arg === '-h' || arg === '--help') {
|
|
55
|
+
help = true;
|
|
56
|
+
} else if (arg === '--local') {
|
|
57
|
+
local = true;
|
|
58
|
+
} else if (arg === '--template') {
|
|
59
|
+
const value = argv[i + 1];
|
|
60
|
+
if (value === undefined) throw new Error('--template requires a value');
|
|
61
|
+
if (!isTemplateName(value)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`unknown template ${JSON.stringify(value)} (expected: ${TEMPLATES.join(', ')})`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
template = value;
|
|
67
|
+
i += 1;
|
|
68
|
+
} else if (arg?.startsWith('-')) {
|
|
69
|
+
throw new Error(`unknown option ${JSON.stringify(arg)}\n\n${usage()}`);
|
|
70
|
+
} else if (projectName === undefined) {
|
|
71
|
+
projectName = arg;
|
|
72
|
+
} else {
|
|
73
|
+
throw new Error(`unexpected extra argument ${JSON.stringify(arg)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
help,
|
|
78
|
+
local,
|
|
79
|
+
...(projectName !== undefined ? { projectName } : {}),
|
|
80
|
+
...(template !== undefined ? { template } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function prompt(question: string): Promise<string> {
|
|
85
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
86
|
+
try {
|
|
87
|
+
return (await rl.question(question)).trim();
|
|
88
|
+
} finally {
|
|
89
|
+
rl.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function resolveTemplate(
|
|
94
|
+
supplied: TemplateName | undefined,
|
|
95
|
+
): Promise<TemplateName> {
|
|
96
|
+
if (supplied !== undefined) return supplied;
|
|
97
|
+
const answer = await prompt(
|
|
98
|
+
`Template? (${TEMPLATES.join(' / ')}) [minimal]: `,
|
|
99
|
+
);
|
|
100
|
+
if (answer === '') return 'minimal';
|
|
101
|
+
if (!isTemplateName(answer)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`unknown template ${JSON.stringify(answer)} (expected: ${TEMPLATES.join(', ')})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return answer;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function nextSteps(dir: string, template: TemplateName): string {
|
|
110
|
+
const cd = ` cd ${dir}\n bun install`;
|
|
111
|
+
const run =
|
|
112
|
+
template === 'web'
|
|
113
|
+
? ' bun run dev # http://localhost:8787'
|
|
114
|
+
: ' bun run server # terminal 1\n bun run clients # terminal 2 — prints "✓ converged"';
|
|
115
|
+
return `\nDone. Next steps:\n\n${cd}\n bun run generate # migrations → src/syncular.generated.ts\n${run}\n\nSee README.md for the layout and what to edit first.\n`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function runCli(argv = process.argv.slice(2)): Promise<number> {
|
|
119
|
+
let args: ParsedArgs;
|
|
120
|
+
try {
|
|
121
|
+
args = parseArgs(argv);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(`[${CREATE_BIN}] ${describe(error)}`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
if (args.help) {
|
|
127
|
+
console.log(usage());
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
let projectName = args.projectName;
|
|
133
|
+
if (projectName === undefined || projectName === '') {
|
|
134
|
+
projectName = await prompt('Project directory (e.g. my-app): ');
|
|
135
|
+
}
|
|
136
|
+
if (projectName === '') {
|
|
137
|
+
console.error(`[${CREATE_BIN}] a project directory is required`);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
const template = await resolveTemplate(args.template);
|
|
141
|
+
|
|
142
|
+
const result = scaffoldApp({
|
|
143
|
+
template,
|
|
144
|
+
targetDir: projectName,
|
|
145
|
+
local: args.local,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const displayDir = relative(process.cwd(), result.targetDir) || '.';
|
|
149
|
+
console.log(
|
|
150
|
+
`[${CREATE_BIN}] scaffolded ${result.packageName} (template: ${template}) into ${displayDir}`,
|
|
151
|
+
);
|
|
152
|
+
if (!result.local) {
|
|
153
|
+
console.warn(
|
|
154
|
+
`[${CREATE_BIN}] note: the ${PRODUCT_NAME} v2 packages are not published yet, so\n` +
|
|
155
|
+
` the app's dependencies are still workspace:* ranges. Run inside this repo\n` +
|
|
156
|
+
` (or pass --local) until publishing lands (TODO 6.3). Use \`${CLI_BIN} generate\`\n` +
|
|
157
|
+
' to (re)generate the typed schema.',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
console.log(nextSteps(displayDir, template));
|
|
161
|
+
return 0;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error(`[${CREATE_BIN}] ${describe(error)}`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function describe(error: unknown): string {
|
|
169
|
+
return error instanceof Error ? error.message : String(error);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (import.meta.main) {
|
|
173
|
+
process.exitCode = await runCli();
|
|
174
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE place naming lives (TODO 6.3 is a product decision — final package
|
|
3
|
+
* identity is not settled). Every user-visible name the scaffolder emits comes
|
|
4
|
+
* from here so a rename is mechanical: change these constants, regenerate the
|
|
5
|
+
* templates' generated files, done. No name is hardcoded elsewhere in this
|
|
6
|
+
* package — grep for the literals below and you should only find them here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The scoped npm namespace for the v2 packages, e.g. `@syncular`. */
|
|
10
|
+
export const PACKAGE_SCOPE = '@syncular';
|
|
11
|
+
|
|
12
|
+
/** The typegen CLI binary name (`syncular generate`, `syncular init`). */
|
|
13
|
+
export const CLI_BIN = 'syncular';
|
|
14
|
+
|
|
15
|
+
/** This package's own bin (`bun create syncular …` / `bunx create-syncular-app …`). */
|
|
16
|
+
export const CREATE_BIN = 'create-syncular-app';
|
|
17
|
+
|
|
18
|
+
/** Human-facing product name in prose. */
|
|
19
|
+
export const PRODUCT_NAME = 'syncular';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The scoped workspace packages a template may depend on. The scaffolder only
|
|
23
|
+
* rewrites ranges for packages under {@link PACKAGE_SCOPE}, so this list is
|
|
24
|
+
* documentation, not a gate — but it keeps the "what does a template pull in"
|
|
25
|
+
* question answerable from one spot.
|
|
26
|
+
*/
|
|
27
|
+
export const WORKSPACE_PACKAGES = [
|
|
28
|
+
`${PACKAGE_SCOPE}/core`,
|
|
29
|
+
`${PACKAGE_SCOPE}/server`,
|
|
30
|
+
`${PACKAGE_SCOPE}/server-hono`,
|
|
31
|
+
`${PACKAGE_SCOPE}/client`,
|
|
32
|
+
`${PACKAGE_SCOPE}/typegen`,
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The dependency range written into a scaffolded app's package.json when NOT
|
|
37
|
+
* scaffolding for in-tree testing (see {@link scaffoldApp}'s `local` flag).
|
|
38
|
+
*
|
|
39
|
+
* The v2 packages are unpublished and version-less today (all `private`, no
|
|
40
|
+
* `version` field — final identity + the release train is TODO 6.3). Until
|
|
41
|
+
* they publish there is no honest semver range to point at, so this is a
|
|
42
|
+
* `workspace:*`-shaped LOCAL default too, and the CLI warns loudly that a
|
|
43
|
+
* published install is not yet possible. When the packages ship, replace this
|
|
44
|
+
* with `^<version>` (or teach the CLI to read the published version) — one
|
|
45
|
+
* edit, here.
|
|
46
|
+
*/
|
|
47
|
+
export const PUBLISHED_DEPENDENCY_RANGE = 'workspace:*';
|
|
48
|
+
|
|
49
|
+
/** Placeholder tokens substituted verbatim during scaffold (dumb + greppable). */
|
|
50
|
+
export const PLACEHOLDER = {
|
|
51
|
+
/** Replaced by the chosen project name in package.json/README. */
|
|
52
|
+
projectName: '__PROJECT_NAME__',
|
|
53
|
+
} as const;
|
package/src/index.ts
ADDED
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scaffolder's heart: copy a template tree into a target directory, with
|
|
3
|
+
* dumb, greppable placeholder substitution and a single dependency-range
|
|
4
|
+
* decision. Kept pure of process/argv concerns so tests drive it directly.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
cpSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { basename, join, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import {
|
|
17
|
+
PACKAGE_SCOPE,
|
|
18
|
+
PLACEHOLDER,
|
|
19
|
+
PUBLISHED_DEPENDENCY_RANGE,
|
|
20
|
+
} from './constants';
|
|
21
|
+
|
|
22
|
+
/** The two templates this rung ships. */
|
|
23
|
+
export const TEMPLATES = ['minimal', 'web'] as const;
|
|
24
|
+
export type TemplateName = (typeof TEMPLATES)[number];
|
|
25
|
+
|
|
26
|
+
export function isTemplateName(value: string): value is TemplateName {
|
|
27
|
+
return (TEMPLATES as readonly string[]).includes(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Absolute path to `template/` inside this package (works for src or dist). */
|
|
31
|
+
export function templatesRoot(): string {
|
|
32
|
+
return fileURLToPath(new URL('../template', import.meta.url));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Derive a safe npm package name from the target directory basename. */
|
|
36
|
+
export function packageNameFromDirectory(targetDir: string): string {
|
|
37
|
+
const name = basename(resolve(targetDir))
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9-_.~]+/g, '-')
|
|
40
|
+
.replace(/^[-._]+|[-._]+$/g, '');
|
|
41
|
+
return name.length > 0 ? name : `${PACKAGE_SCOPE.slice(1)}-app`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isWorkspacePackage(dependencyName: string): boolean {
|
|
45
|
+
return dependencyName.startsWith(`${PACKAGE_SCOPE}/`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rewrite a template's package.json: set `name`, and resolve the dependency
|
|
50
|
+
* range for every `@<scope>/*` workspace dependency.
|
|
51
|
+
*
|
|
52
|
+
* The local-vs-published question, decided honestly (also documented in the
|
|
53
|
+
* package README):
|
|
54
|
+
* - `local: true` keeps `workspace:*` verbatim — the only ranges that resolve
|
|
55
|
+
* when the scaffolded app sits inside this repo's workspace (the in-tree
|
|
56
|
+
* smoke test path, and `--local` for anyone hacking on the tree).
|
|
57
|
+
* - `local: false` rewrites to {@link PUBLISHED_DEPENDENCY_RANGE}. Today that
|
|
58
|
+
* is *also* `workspace:*` because the v2 packages are unpublished and
|
|
59
|
+
* version-less (TODO 6.3); the CLI warns. It is one constant to flip the day
|
|
60
|
+
* they publish.
|
|
61
|
+
*/
|
|
62
|
+
export function rewriteTemplatePackageJson(
|
|
63
|
+
source: string,
|
|
64
|
+
options: { packageName: string; local: boolean },
|
|
65
|
+
): string {
|
|
66
|
+
const pkg = JSON.parse(source) as {
|
|
67
|
+
name?: string;
|
|
68
|
+
dependencies?: Record<string, string>;
|
|
69
|
+
devDependencies?: Record<string, string>;
|
|
70
|
+
};
|
|
71
|
+
pkg.name = options.packageName;
|
|
72
|
+
const range = options.local ? 'workspace:*' : PUBLISHED_DEPENDENCY_RANGE;
|
|
73
|
+
for (const section of [pkg.dependencies, pkg.devDependencies]) {
|
|
74
|
+
if (section === undefined) continue;
|
|
75
|
+
for (const [name, current] of Object.entries(section)) {
|
|
76
|
+
if (isWorkspacePackage(name) && current.startsWith('workspace:')) {
|
|
77
|
+
section[name] = range;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Replace `__PROJECT_NAME__` everywhere in a text file body. */
|
|
85
|
+
function substitutePlaceholders(body: string, projectName: string): string {
|
|
86
|
+
return body.replaceAll(PLACEHOLDER.projectName, projectName);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ScaffoldOptions {
|
|
90
|
+
readonly template: TemplateName;
|
|
91
|
+
readonly targetDir: string;
|
|
92
|
+
/** Keep `workspace:*` ranges (in-tree testing / repo hacking). */
|
|
93
|
+
readonly local?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ScaffoldResult {
|
|
97
|
+
readonly targetDir: string;
|
|
98
|
+
readonly packageName: string;
|
|
99
|
+
readonly template: TemplateName;
|
|
100
|
+
readonly local: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Files whose bodies get `__PROJECT_NAME__` substitution. Everything else is
|
|
105
|
+
* copied byte-for-byte. Deliberately a short allow-list (missing paths are
|
|
106
|
+
* skipped per-template) — substitution stays dumb and auditable rather than
|
|
107
|
+
* scanning every file. Paths are relative + POSIX-joined below.
|
|
108
|
+
*/
|
|
109
|
+
const SUBSTITUTE_FILES = ['README.md', 'src/frontend/index.html'] as const;
|
|
110
|
+
|
|
111
|
+
function directoryIsEmpty(path: string): boolean {
|
|
112
|
+
return readdirSync(path).length === 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Copy `template/<template>` into `targetDir` and finalize it: restore the
|
|
117
|
+
* `.gitignore` name (npm strips real dotfiles from tarballs, so templates ship
|
|
118
|
+
* `gitignore`), rewrite package.json, and substitute placeholders.
|
|
119
|
+
*/
|
|
120
|
+
export function scaffoldApp(options: ScaffoldOptions): ScaffoldResult {
|
|
121
|
+
const targetDir = resolve(options.targetDir);
|
|
122
|
+
const templateDir = join(templatesRoot(), options.template);
|
|
123
|
+
const local = options.local ?? false;
|
|
124
|
+
|
|
125
|
+
if (!existsSync(templateDir)) {
|
|
126
|
+
throw new Error(`unknown template directory: ${templateDir}`);
|
|
127
|
+
}
|
|
128
|
+
if (existsSync(targetDir) && !directoryIsEmpty(targetDir)) {
|
|
129
|
+
throw new Error(`target directory is not empty: ${targetDir}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
cpSync(templateDir, targetDir, { recursive: true });
|
|
133
|
+
|
|
134
|
+
const gitignorePlaceholder = join(targetDir, 'gitignore');
|
|
135
|
+
if (existsSync(gitignorePlaceholder)) {
|
|
136
|
+
renameSync(gitignorePlaceholder, join(targetDir, '.gitignore'));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const packageName = packageNameFromDirectory(targetDir);
|
|
140
|
+
|
|
141
|
+
const packageJsonPath = join(targetDir, 'package.json');
|
|
142
|
+
writeFileSync(
|
|
143
|
+
packageJsonPath,
|
|
144
|
+
rewriteTemplatePackageJson(readFileSync(packageJsonPath, 'utf8'), {
|
|
145
|
+
packageName,
|
|
146
|
+
local,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
for (const relPath of SUBSTITUTE_FILES) {
|
|
151
|
+
const path = join(targetDir, relPath);
|
|
152
|
+
if (existsSync(path)) {
|
|
153
|
+
writeFileSync(
|
|
154
|
+
path,
|
|
155
|
+
substitutePlaceholders(readFileSync(path, 'utf8'), packageName),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { targetDir, packageName, template: options.template, local };
|
|
161
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
A minimal [syncular](https://github.com/bkniffler/syncular) v2 app: a schema, a
|
|
4
|
+
~30-line server, and two client cores that converge — all in a terminal, no
|
|
5
|
+
browser. This is the shape you grow a real backend from.
|
|
6
|
+
|
|
7
|
+
## Run it
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun install
|
|
11
|
+
bun run generate # syncular.json + migrations → src/syncular.generated.ts
|
|
12
|
+
bun run server # terminal 1 — http://localhost:8787
|
|
13
|
+
bun run clients # terminal 2 — prints "✓ converged"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`bun test` runs the same convergence path as a smoke test.
|
|
17
|
+
|
|
18
|
+
## Layout
|
|
19
|
+
|
|
20
|
+
| File | What it is |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `syncular.json` + `migrations/` | The schema manifest and SQL — typegen's input |
|
|
23
|
+
| `src/syncular.generated.ts` | Generated by `syncular generate` (committed; regenerate on schema change) |
|
|
24
|
+
| `src/server.ts` | The whole sync backend in one Bun process |
|
|
25
|
+
| `src/make-client.ts` | Constructs one `SyncClient` on bun:sqlite + fetch |
|
|
26
|
+
| `src/clients.ts` | Two clients, one server, terminal-visible convergence |
|
|
27
|
+
| `src/smoke.test.ts` | The end-to-end smoke test (`bun test`) |
|
|
28
|
+
|
|
29
|
+
## What to edit first
|
|
30
|
+
|
|
31
|
+
1. **`src/server.ts` → `resolveScopes`** — the whole authorization story. It
|
|
32
|
+
runs in your backend next to your auth. The starter returns `['*']` (see
|
|
33
|
+
everything); a real one returns the scope values the authenticated actor may
|
|
34
|
+
see.
|
|
35
|
+
2. **`src/server.ts` → `authenticate`** — plug in your real session/token
|
|
36
|
+
check; return `{ actorId, partition }` or `null` for a 401.
|
|
37
|
+
3. **`migrations/` + `syncular.json`** — add tables and scopes, then
|
|
38
|
+
`bun run generate` to refresh the typed schema.
|
|
39
|
+
|
|
40
|
+
## Next
|
|
41
|
+
|
|
42
|
+
- Want a browser app? Scaffold the `web` template instead
|
|
43
|
+
(`bun create syncular-app my-app --template web`).
|
|
44
|
+
- The [docs](https://github.com/bkniffler/syncular) explain scopes, conflicts,
|
|
45
|
+
bootstrap, realtime, and blobs.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Your schema, one table to start. typegen reads this to derive the schema
|
|
2
|
+
-- SHAPE (column types, primary key); the server manages its own sync_* tables
|
|
3
|
+
-- and never runs this migration.
|
|
4
|
+
CREATE TABLE notes (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
list_id TEXT NOT NULL,
|
|
7
|
+
body TEXT NOT NULL,
|
|
8
|
+
updated_at_ms INTEGER NOT NULL
|
|
9
|
+
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"generate": "syncular generate --manifest-dir .",
|
|
7
|
+
"server": "bun run src/server.ts",
|
|
8
|
+
"clients": "bun run src/clients.ts",
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@syncular/core": "workspace:*",
|
|
13
|
+
"@syncular/server": "workspace:*",
|
|
14
|
+
"@syncular/server-hono": "workspace:*",
|
|
15
|
+
"@syncular/client": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@syncular/typegen": "workspace:*",
|
|
19
|
+
"@types/bun": "latest",
|
|
20
|
+
"typescript": "^5.9.0"
|
|
21
|
+
}
|
|
22
|
+
}
|