@hatk/hatk 0.0.1-alpha.11 → 0.0.1-alpha.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +19 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +113 -0
- package/dist/backfill.d.ts +36 -2
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +153 -37
- package/dist/car.js +1 -1
- package/dist/classifiers.d.ts +228 -0
- package/dist/classifiers.d.ts.map +1 -0
- package/dist/classifiers.js +688 -0
- package/dist/cli.js +248 -1043
- package/dist/config.d.ts +179 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +78 -8
- package/dist/database/adapter-factory.d.ts +6 -0
- package/dist/database/adapter-factory.d.ts.map +1 -0
- package/dist/database/adapter-factory.js +20 -0
- package/dist/database/adapters/duckdb-search.d.ts +12 -0
- package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
- package/dist/database/adapters/duckdb-search.js +27 -0
- package/dist/database/adapters/duckdb.d.ts +25 -0
- package/dist/database/adapters/duckdb.d.ts.map +1 -0
- package/dist/database/adapters/duckdb.js +161 -0
- package/dist/database/adapters/sqlite-search.d.ts +16 -0
- package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
- package/dist/database/adapters/sqlite-search.js +76 -0
- package/dist/database/adapters/sqlite.d.ts +18 -0
- package/dist/database/adapters/sqlite.d.ts.map +1 -0
- package/dist/database/adapters/sqlite.js +88 -0
- package/dist/database/db.d.ts +290 -0
- package/dist/database/db.d.ts.map +1 -0
- package/dist/database/db.js +2073 -0
- package/dist/database/dialect.d.ts +45 -0
- package/dist/database/dialect.d.ts.map +1 -0
- package/dist/database/dialect.js +72 -0
- package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
- package/dist/database/fts.d.ts.map +1 -0
- package/dist/{fts.js → database/fts.js} +122 -34
- package/dist/database/index.d.ts +7 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +6 -0
- package/dist/database/ports.d.ts +50 -0
- package/dist/database/ports.d.ts.map +1 -0
- package/dist/database/ports.js +1 -0
- package/dist/database/schema.d.ts +91 -0
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/{schema.js → database/schema.js} +153 -54
- package/dist/dev-entry.d.ts +8 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +131 -0
- package/dist/feeds.d.ts +26 -8
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +72 -16
- package/dist/hooks.d.ts +102 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +186 -0
- package/dist/hydrate.d.ts +55 -6
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +21 -20
- package/dist/indexer.d.ts +194 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +627 -92
- package/dist/jetstream.d.ts +118 -0
- package/dist/jetstream.d.ts.map +1 -0
- package/dist/jetstream.js +238 -0
- package/dist/labels.d.ts +36 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +71 -6
- package/dist/lex-types.d.ts +2 -0
- package/dist/lex-types.d.ts.map +1 -1
- package/dist/lexicon-resolve.d.ts.map +1 -1
- package/dist/lexicon-resolve.js +27 -112
- package/dist/lexicons/com/atproto/label/defs.json +75 -0
- package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
- package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
- package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
- package/dist/lexicons/dev/hatk/createRecord.json +40 -0
- package/dist/lexicons/dev/hatk/createReport.json +48 -0
- package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
- package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
- package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
- package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
- package/dist/lexicons/dev/hatk/getFeed.json +30 -0
- package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
- package/dist/lexicons/dev/hatk/getRecord.json +26 -0
- package/dist/lexicons/dev/hatk/getRecords.json +32 -0
- package/dist/lexicons/dev/hatk/putPreference.json +28 -0
- package/dist/lexicons/dev/hatk/putRecord.json +41 -0
- package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
- package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +29 -0
- package/dist/main.js +212 -75
- package/dist/mst.d.ts +15 -0
- package/dist/mst.d.ts.map +1 -1
- package/dist/mst.js +13 -0
- package/dist/oauth/db.d.ts +27 -1
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +80 -19
- package/dist/oauth/discovery.d.ts +10 -0
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +54 -2
- package/dist/oauth/server.d.ts +131 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +504 -39
- package/dist/oauth/service-describe.d.ts +42 -0
- package/dist/oauth/service-describe.d.ts.map +1 -0
- package/dist/oauth/service-describe.js +108 -0
- package/dist/oauth/session.d.ts +32 -0
- package/dist/oauth/session.d.ts.map +1 -0
- package/dist/oauth/session.js +131 -0
- package/dist/opengraph.d.ts +10 -0
- package/dist/opengraph.d.ts.map +1 -1
- package/dist/opengraph.js +94 -42
- package/dist/pds-proxy.d.ts +92 -0
- package/dist/pds-proxy.d.ts.map +1 -0
- package/dist/pds-proxy.js +396 -0
- package/dist/private-collections.d.ts +13 -0
- package/dist/private-collections.d.ts.map +1 -0
- package/dist/private-collections.js +27 -0
- package/dist/push.d.ts +54 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +367 -0
- package/dist/renderer.d.ts +27 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +46 -0
- package/dist/resolve-hatk.d.ts +6 -0
- package/dist/resolve-hatk.d.ts.map +1 -0
- package/dist/resolve-hatk.js +20 -0
- package/dist/response.d.ts +16 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +69 -0
- package/dist/scanner.d.ts +22 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +92 -0
- package/dist/seed.d.ts +19 -0
- package/dist/seed.d.ts.map +1 -1
- package/dist/seed.js +43 -4
- package/dist/server-init.d.ts +8 -0
- package/dist/server-init.d.ts.map +1 -0
- package/dist/server-init.js +75 -0
- package/dist/server.d.ts +46 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1137 -649
- package/dist/setup.d.ts +28 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +50 -3
- package/dist/spaces/blob.d.ts +112 -0
- package/dist/spaces/blob.d.ts.map +1 -0
- package/dist/spaces/blob.js +460 -0
- package/dist/spaces/credential.d.ts +82 -0
- package/dist/spaces/credential.d.ts.map +1 -0
- package/dist/spaces/credential.js +220 -0
- package/dist/spaces/engine.d.ts +97 -0
- package/dist/spaces/engine.d.ts.map +1 -0
- package/dist/spaces/engine.js +563 -0
- package/dist/spaces/guard.d.ts +41 -0
- package/dist/spaces/guard.d.ts.map +1 -0
- package/dist/spaces/guard.js +79 -0
- package/dist/spaces/identity.d.ts +37 -0
- package/dist/spaces/identity.d.ts.map +1 -0
- package/dist/spaces/identity.js +93 -0
- package/dist/spaces/index.d.ts +56 -0
- package/dist/spaces/index.d.ts.map +1 -0
- package/dist/spaces/index.js +134 -0
- package/dist/spaces/notify.d.ts +60 -0
- package/dist/spaces/notify.d.ts.map +1 -0
- package/dist/spaces/notify.js +128 -0
- package/dist/spaces/records.d.ts +37 -0
- package/dist/spaces/records.d.ts.map +1 -0
- package/dist/spaces/records.js +63 -0
- package/dist/spaces/signature.d.ts +36 -0
- package/dist/spaces/signature.d.ts.map +1 -0
- package/dist/spaces/signature.js +83 -0
- package/dist/spaces/store.d.ts +51 -0
- package/dist/spaces/store.d.ts.map +1 -0
- package/dist/spaces/store.js +99 -0
- package/dist/spaces/uri.d.ts +60 -0
- package/dist/spaces/uri.d.ts.map +1 -0
- package/dist/spaces/uri.js +97 -0
- package/dist/spaces/verify.d.ts +46 -0
- package/dist/spaces/verify.d.ts.map +1 -0
- package/dist/spaces/verify.js +94 -0
- package/dist/spaces/viewer.d.ts +51 -0
- package/dist/spaces/viewer.d.ts.map +1 -0
- package/dist/spaces/viewer.js +167 -0
- package/dist/spaces/visibility.d.ts +57 -0
- package/dist/spaces/visibility.d.ts.map +1 -0
- package/dist/spaces/visibility.js +81 -0
- package/dist/templates/feed.tpl +18 -0
- package/dist/templates/hook.tpl +5 -0
- package/dist/templates/label.tpl +15 -0
- package/dist/templates/og.tpl +17 -0
- package/dist/templates/seed.tpl +11 -0
- package/dist/templates/setup.tpl +5 -0
- package/dist/templates/test-feed.tpl +19 -0
- package/dist/templates/test-xrpc.tpl +19 -0
- package/dist/templates/xrpc.tpl +53 -0
- package/dist/test.d.ts +3 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +60 -36
- package/dist/views.js +1 -1
- package/dist/vite-plugin.d.ts +1 -1
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +254 -66
- package/dist/xrpc.d.ts +143 -12
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +282 -55
- package/package.json +29 -9
- package/public/admin-auth.js +271 -306
- package/public/admin.html +652 -61
- package/dist/db.d.ts +0 -135
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js +0 -1365
- package/dist/fts.d.ts.map +0 -1
- package/dist/oauth/hooks.d.ts +0 -10
- package/dist/oauth/hooks.d.ts.map +0 -1
- package/dist/oauth/hooks.js +0 -40
- package/dist/schema.d.ts +0 -51
- package/dist/schema.d.ts.map +0 -1
- package/dist/test-browser.d.ts +0 -14
- package/dist/test-browser.d.ts.map +0 -1
- package/dist/test-browser.js +0 -26
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { mkdirSync, writeFileSync, existsSync, unlinkSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { resolve, join } from 'node:path';
|
|
4
|
-
import { execSync } from 'node:child_process';
|
|
5
|
-
import { loadLexicons } from "./schema.js";
|
|
4
|
+
import { execSync, spawn } from 'node:child_process';
|
|
5
|
+
import { loadLexicons } from "./database/schema.js";
|
|
6
6
|
import { loadConfig } from "./config.js";
|
|
7
7
|
const args = process.argv.slice(2);
|
|
8
8
|
const command = args[0];
|
|
@@ -34,6 +34,31 @@ async function ensurePds() {
|
|
|
34
34
|
console.error('[dev] PDS failed to start');
|
|
35
35
|
process.exit(1);
|
|
36
36
|
}
|
|
37
|
+
/** Spawn a long-running process and forward SIGINT/SIGTERM for clean shutdown. */
|
|
38
|
+
function spawnForward(cmd, args, env) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const child = spawn(cmd, args, {
|
|
41
|
+
stdio: 'inherit',
|
|
42
|
+
cwd: process.cwd(),
|
|
43
|
+
env: { ...process.env, ...env },
|
|
44
|
+
});
|
|
45
|
+
const onSignal = (sig) => {
|
|
46
|
+
child.kill(sig);
|
|
47
|
+
};
|
|
48
|
+
process.on('SIGINT', onSignal);
|
|
49
|
+
process.on('SIGTERM', onSignal);
|
|
50
|
+
child.on('close', (code, signal) => {
|
|
51
|
+
process.removeListener('SIGINT', onSignal);
|
|
52
|
+
process.removeListener('SIGTERM', onSignal);
|
|
53
|
+
if (signal === 'SIGINT' || signal === 'SIGTERM')
|
|
54
|
+
process.exit(0);
|
|
55
|
+
if (code === 0 || code === null)
|
|
56
|
+
resolve();
|
|
57
|
+
else
|
|
58
|
+
reject(new Error(`Process exited with code ${code}`));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
37
62
|
function runSeed() {
|
|
38
63
|
const seedFile = resolve('seeds/seed.ts');
|
|
39
64
|
if (!existsSync(seedFile))
|
|
@@ -44,15 +69,11 @@ function usage() {
|
|
|
44
69
|
console.log(`
|
|
45
70
|
Usage: hatk <command> [options]
|
|
46
71
|
|
|
47
|
-
Getting Started
|
|
48
|
-
new <name> [--svelte] [--template <t>] Create a new hatk project
|
|
49
|
-
|
|
50
72
|
Running
|
|
51
73
|
start Start the hatk server
|
|
52
74
|
dev Start PDS, seed, and run hatk
|
|
53
75
|
seed Seed local PDS with fixture data
|
|
54
76
|
reset Reset database and PDS for a clean slate
|
|
55
|
-
schema Show database schema from lexicons
|
|
56
77
|
|
|
57
78
|
Code Quality
|
|
58
79
|
check Type-check and lint the project
|
|
@@ -70,7 +91,8 @@ function usage() {
|
|
|
70
91
|
generate xrpc <nsid> Generate an XRPC handler
|
|
71
92
|
generate label <name> Generate a label definition
|
|
72
93
|
generate og <name> Generate an OpenGraph route
|
|
73
|
-
generate
|
|
94
|
+
generate hook <name> Generate a lifecycle hook
|
|
95
|
+
generate setup <name> Generate a setup script
|
|
74
96
|
generate types Regenerate TypeScript types from lexicons
|
|
75
97
|
destroy <type> <name> Remove a generated file
|
|
76
98
|
|
|
@@ -82,151 +104,16 @@ function usage() {
|
|
|
82
104
|
if (!command)
|
|
83
105
|
usage();
|
|
84
106
|
// --- Templates ---
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
label: '${name.charAt(0).toUpperCase() + name.slice(1)}',
|
|
91
|
-
|
|
92
|
-
async generate(ctx) {
|
|
93
|
-
const { rows, cursor } = await ctx.paginate<{ uri: string }>(
|
|
94
|
-
\`SELECT uri, cid, indexed_at FROM "your.collection.here"\`,
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
return ctx.ok({ uris: rows.map((r) => r.uri), cursor })
|
|
98
|
-
},
|
|
99
|
-
})
|
|
100
|
-
`,
|
|
101
|
-
xrpc: (name) => `import { defineQuery } from '${xrpcImportPath(name)}'
|
|
102
|
-
|
|
103
|
-
export default defineQuery('${name}', async (ctx) => {
|
|
104
|
-
const { ok, db, params, packCursor, unpackCursor } = ctx
|
|
105
|
-
const limit = params.limit ?? 30
|
|
106
|
-
const cursor = params.cursor
|
|
107
|
-
|
|
108
|
-
const conditions: string[] = []
|
|
109
|
-
const sqlParams: (string | number)[] = []
|
|
110
|
-
let paramIdx = 1
|
|
111
|
-
|
|
112
|
-
if (cursor) {
|
|
113
|
-
const parsed = unpackCursor(cursor)
|
|
114
|
-
if (parsed) {
|
|
115
|
-
conditions.push(\`(s.indexed_at < $\${paramIdx} OR (s.indexed_at = $\${paramIdx + 1} AND s.cid < $\${paramIdx + 2}))\`)
|
|
116
|
-
sqlParams.push(parsed.primary, parsed.primary, parsed.cid)
|
|
117
|
-
paramIdx += 3
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
|
|
122
|
-
|
|
123
|
-
const rows = await db.query(
|
|
124
|
-
\`SELECT s.* FROM "your.collection.here" s \${where} ORDER BY s.indexed_at DESC, s.cid DESC LIMIT $\${paramIdx}\`,
|
|
125
|
-
sqlParams.concat([limit + 1]),
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
const hasMore = rows.length > limit
|
|
129
|
-
if (hasMore) rows.pop()
|
|
130
|
-
const lastRow = rows[rows.length - 1]
|
|
131
|
-
|
|
132
|
-
return ok({
|
|
133
|
-
items: rows,
|
|
134
|
-
cursor: hasMore && lastRow ? packCursor(lastRow.indexed_at, lastRow.cid) : undefined,
|
|
135
|
-
})
|
|
136
|
-
})
|
|
137
|
-
`,
|
|
138
|
-
label: (name) => `import type { LabelRuleContext } from '@hatk/hatk/labels'
|
|
139
|
-
|
|
140
|
-
export default {
|
|
141
|
-
definition: {
|
|
142
|
-
identifier: '${name}',
|
|
143
|
-
severity: 'inform',
|
|
144
|
-
blurs: 'none',
|
|
145
|
-
defaultSetting: 'warn',
|
|
146
|
-
locales: [{ lang: 'en', name: '${name.charAt(0).toUpperCase() + name.slice(1)}', description: 'Description here' }],
|
|
147
|
-
},
|
|
148
|
-
async evaluate(ctx: LabelRuleContext) {
|
|
149
|
-
// Return array of label identifiers to apply, or empty array
|
|
150
|
-
return []
|
|
151
|
-
},
|
|
107
|
+
const templateDir = join(import.meta.dirname, 'templates');
|
|
108
|
+
function loadTemplate(file, name) {
|
|
109
|
+
const raw = readFileSync(join(templateDir, file), 'utf-8');
|
|
110
|
+
const capitalized = name.charAt(0).toUpperCase() + name.slice(1);
|
|
111
|
+
return raw.replaceAll('{{name}}', name).replaceAll('{{Name}}', capitalized);
|
|
152
112
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
path: '/og/${name}/:id',
|
|
158
|
-
async generate(ctx: OpengraphContext): Promise<OpengraphResult> {
|
|
159
|
-
const { db, params } = ctx
|
|
160
|
-
return {
|
|
161
|
-
element: {
|
|
162
|
-
type: 'div',
|
|
163
|
-
props: {
|
|
164
|
-
style: { display: 'flex', width: '100%', height: '100%', background: '#080b12', color: 'white', alignItems: 'center', justifyContent: 'center' },
|
|
165
|
-
children: params.id,
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
}
|
|
169
|
-
},
|
|
170
|
-
}
|
|
171
|
-
`,
|
|
172
|
-
job: (_name) => `export default {
|
|
173
|
-
interval: 300, // seconds
|
|
174
|
-
async run(_ctx: any) {
|
|
175
|
-
// Periodic task logic here
|
|
176
|
-
},
|
|
177
|
-
}
|
|
178
|
-
`,
|
|
179
|
-
};
|
|
180
|
-
// Compute relative import path from xrpc/ns/id/method.ts back to hatk.generated.ts
|
|
181
|
-
// e.g. fm.teal.getStats → xrpc/fm/teal/getStats.ts → needs ../../../hatk.generated.ts
|
|
182
|
-
// Parts: [fm, teal, getStats] → 2 namespace dirs + xrpc/ dir = 3 levels up
|
|
183
|
-
function xrpcImportPath(nsid) {
|
|
184
|
-
const parts = nsid.split('.');
|
|
185
|
-
const namespaceDirs = parts.length - 1; // dirs created from namespace segments
|
|
186
|
-
return '../'.repeat(namespaceDirs + 1) + 'hatk.generated.ts'; // +1 for xrpc/ dir itself
|
|
187
|
-
}
|
|
188
|
-
const testTemplates = {
|
|
189
|
-
feed: (name) => `import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
190
|
-
import { createTestContext } from '@hatk/hatk/test'
|
|
191
|
-
|
|
192
|
-
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
|
193
|
-
|
|
194
|
-
beforeAll(async () => {
|
|
195
|
-
ctx = await createTestContext()
|
|
196
|
-
await ctx.loadFixtures()
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
afterAll(async () => ctx?.close())
|
|
200
|
-
|
|
201
|
-
describe('${name} feed', () => {
|
|
202
|
-
test('returns results', async () => {
|
|
203
|
-
const feed = ctx.loadFeed('${name}')
|
|
204
|
-
const result = await feed.generate(ctx.feedContext({ limit: 10 }))
|
|
205
|
-
expect(result).toBeDefined()
|
|
206
|
-
})
|
|
207
|
-
})
|
|
208
|
-
`,
|
|
209
|
-
xrpc: (name) => `import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
210
|
-
import { createTestContext } from '@hatk/hatk/test'
|
|
211
|
-
|
|
212
|
-
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
|
213
|
-
|
|
214
|
-
beforeAll(async () => {
|
|
215
|
-
ctx = await createTestContext()
|
|
216
|
-
await ctx.loadFixtures()
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
afterAll(async () => ctx?.close())
|
|
220
|
-
|
|
221
|
-
describe('${name}', () => {
|
|
222
|
-
test('returns response', async () => {
|
|
223
|
-
const handler = ctx.loadXrpc('${name}')
|
|
224
|
-
const result = await handler.handler({ params: {} })
|
|
225
|
-
expect(result).toBeDefined()
|
|
226
|
-
})
|
|
227
|
-
})
|
|
228
|
-
`,
|
|
229
|
-
};
|
|
113
|
+
const templateTypes = ['feed', 'xrpc', 'label', 'og', 'hook', 'setup'];
|
|
114
|
+
const testTemplateTypes = ['feed', 'xrpc'];
|
|
115
|
+
const templates = Object.fromEntries(templateTypes.map((t) => [t, (name) => loadTemplate(`${t}.tpl`, name)]));
|
|
116
|
+
const testTemplates = Object.fromEntries(testTemplateTypes.map((t) => [t, (name) => loadTemplate(`test-${t}.tpl`, name)]));
|
|
230
117
|
const lexiconTemplates = {
|
|
231
118
|
record: (nsid) => ({
|
|
232
119
|
lexicon: 1,
|
|
@@ -293,815 +180,21 @@ const lexiconTemplates = {
|
|
|
293
180
|
}),
|
|
294
181
|
};
|
|
295
182
|
const dirs = {
|
|
296
|
-
feed: '
|
|
297
|
-
xrpc: '
|
|
298
|
-
label: '
|
|
299
|
-
og: '
|
|
300
|
-
|
|
183
|
+
feed: 'server',
|
|
184
|
+
xrpc: 'server',
|
|
185
|
+
label: 'server',
|
|
186
|
+
og: 'server',
|
|
187
|
+
hook: 'server',
|
|
188
|
+
setup: 'server',
|
|
301
189
|
};
|
|
302
190
|
// --- Commands ---
|
|
303
191
|
if (command === 'new') {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const templateName = templateIdx !== -1 ? args[templateIdx + 1] : null;
|
|
311
|
-
if (templateIdx !== -1 && !templateName) {
|
|
312
|
-
console.error('Usage: hatk new <name> --template <template-name>');
|
|
313
|
-
process.exit(1);
|
|
314
|
-
}
|
|
315
|
-
const dir = resolve(name);
|
|
316
|
-
if (existsSync(dir)) {
|
|
317
|
-
console.error(`Directory ${name} already exists`);
|
|
318
|
-
process.exit(1);
|
|
319
|
-
}
|
|
320
|
-
if (templateName) {
|
|
321
|
-
const repo = `https://github.com/hatk-dev/hatk-template-${templateName}.git`;
|
|
322
|
-
console.log(`Cloning template ${templateName}...`);
|
|
323
|
-
try {
|
|
324
|
-
execSync(`git clone --depth 1 ${repo} ${dir}`, { stdio: 'inherit' });
|
|
325
|
-
}
|
|
326
|
-
catch {
|
|
327
|
-
console.error(`Failed to clone template: ${repo}`);
|
|
328
|
-
process.exit(1);
|
|
329
|
-
}
|
|
330
|
-
execSync(`rm -rf ${join(dir, '.git')}`);
|
|
331
|
-
const pkgPath = join(dir, 'package.json');
|
|
332
|
-
if (existsSync(pkgPath)) {
|
|
333
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
334
|
-
pkg.name = name;
|
|
335
|
-
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
336
|
-
}
|
|
337
|
-
console.log(`\nCreated ${name}/ from template ${templateName}`);
|
|
338
|
-
console.log(`\n cd ${name}`);
|
|
339
|
-
console.log(` npm install`);
|
|
340
|
-
console.log(` hatk dev`);
|
|
341
|
-
process.exit(0);
|
|
342
|
-
}
|
|
343
|
-
const withSvelte = args.includes('--svelte');
|
|
344
|
-
mkdirSync(dir);
|
|
345
|
-
const subs = [
|
|
346
|
-
'lexicons',
|
|
347
|
-
'feeds',
|
|
348
|
-
'xrpc',
|
|
349
|
-
'og',
|
|
350
|
-
'labels',
|
|
351
|
-
'jobs',
|
|
352
|
-
'seeds',
|
|
353
|
-
'setup',
|
|
354
|
-
'public',
|
|
355
|
-
'test',
|
|
356
|
-
'test/feeds',
|
|
357
|
-
'test/xrpc',
|
|
358
|
-
'test/integration',
|
|
359
|
-
'test/browser',
|
|
360
|
-
'test/fixtures',
|
|
361
|
-
];
|
|
362
|
-
if (withSvelte)
|
|
363
|
-
subs.push('src', 'src/routes', 'src/lib');
|
|
364
|
-
for (const sub of subs) {
|
|
365
|
-
mkdirSync(join(dir, sub));
|
|
366
|
-
}
|
|
367
|
-
writeFileSync(join(dir, 'config.yaml'), `relay: ws://localhost:2583
|
|
368
|
-
plc: http://localhost:2582
|
|
369
|
-
port: 3000
|
|
370
|
-
database: data/hatk.db
|
|
371
|
-
admins: []
|
|
372
|
-
|
|
373
|
-
backfill:
|
|
374
|
-
parallelism: 10
|
|
375
|
-
`);
|
|
376
|
-
writeFileSync(join(dir, 'public', 'index.html'), `<!DOCTYPE html>
|
|
377
|
-
<html><head><title>${name}</title></head>
|
|
378
|
-
<body><h1>${name}</h1></body></html>
|
|
379
|
-
`);
|
|
380
|
-
// Scaffold core framework lexicons under dev.hatk namespace
|
|
381
|
-
const coreLexDir = join(dir, 'lexicons', 'dev', 'hatk');
|
|
382
|
-
mkdirSync(coreLexDir, { recursive: true });
|
|
383
|
-
writeFileSync(join(coreLexDir, 'describeCollections.json'), JSON.stringify({
|
|
384
|
-
lexicon: 1,
|
|
385
|
-
id: 'dev.hatk.describeCollections',
|
|
386
|
-
defs: {
|
|
387
|
-
main: {
|
|
388
|
-
type: 'query',
|
|
389
|
-
description: 'List indexed collections and their schemas.',
|
|
390
|
-
output: {
|
|
391
|
-
encoding: 'application/json',
|
|
392
|
-
schema: {
|
|
393
|
-
type: 'object',
|
|
394
|
-
properties: {
|
|
395
|
-
collections: {
|
|
396
|
-
type: 'array',
|
|
397
|
-
items: {
|
|
398
|
-
type: 'object',
|
|
399
|
-
required: ['collection'],
|
|
400
|
-
properties: {
|
|
401
|
-
collection: { type: 'string' },
|
|
402
|
-
columns: {
|
|
403
|
-
type: 'array',
|
|
404
|
-
items: {
|
|
405
|
-
type: 'object',
|
|
406
|
-
required: ['name', 'originalName', 'type', 'required'],
|
|
407
|
-
properties: {
|
|
408
|
-
name: { type: 'string' },
|
|
409
|
-
originalName: { type: 'string' },
|
|
410
|
-
type: { type: 'string' },
|
|
411
|
-
required: { type: 'boolean' },
|
|
412
|
-
},
|
|
413
|
-
},
|
|
414
|
-
},
|
|
415
|
-
},
|
|
416
|
-
},
|
|
417
|
-
},
|
|
418
|
-
},
|
|
419
|
-
},
|
|
420
|
-
},
|
|
421
|
-
},
|
|
422
|
-
},
|
|
423
|
-
}, null, 2) + '\n');
|
|
424
|
-
writeFileSync(join(coreLexDir, 'describeFeeds.json'), JSON.stringify({
|
|
425
|
-
lexicon: 1,
|
|
426
|
-
id: 'dev.hatk.describeFeeds',
|
|
427
|
-
defs: {
|
|
428
|
-
main: {
|
|
429
|
-
type: 'query',
|
|
430
|
-
description: 'List available feeds.',
|
|
431
|
-
output: {
|
|
432
|
-
encoding: 'application/json',
|
|
433
|
-
schema: {
|
|
434
|
-
type: 'object',
|
|
435
|
-
properties: {
|
|
436
|
-
feeds: {
|
|
437
|
-
type: 'array',
|
|
438
|
-
items: {
|
|
439
|
-
type: 'object',
|
|
440
|
-
required: ['name', 'label'],
|
|
441
|
-
properties: {
|
|
442
|
-
name: { type: 'string' },
|
|
443
|
-
label: { type: 'string' },
|
|
444
|
-
},
|
|
445
|
-
},
|
|
446
|
-
},
|
|
447
|
-
},
|
|
448
|
-
},
|
|
449
|
-
},
|
|
450
|
-
},
|
|
451
|
-
},
|
|
452
|
-
}, null, 2) + '\n');
|
|
453
|
-
writeFileSync(join(coreLexDir, 'describeLabels.json'), JSON.stringify({
|
|
454
|
-
lexicon: 1,
|
|
455
|
-
id: 'dev.hatk.describeLabels',
|
|
456
|
-
defs: {
|
|
457
|
-
main: {
|
|
458
|
-
type: 'query',
|
|
459
|
-
description: 'List available label definitions.',
|
|
460
|
-
output: {
|
|
461
|
-
encoding: 'application/json',
|
|
462
|
-
schema: {
|
|
463
|
-
type: 'object',
|
|
464
|
-
properties: {
|
|
465
|
-
definitions: {
|
|
466
|
-
type: 'array',
|
|
467
|
-
items: {
|
|
468
|
-
type: 'object',
|
|
469
|
-
required: ['identifier', 'severity', 'blurs', 'defaultSetting'],
|
|
470
|
-
properties: {
|
|
471
|
-
identifier: { type: 'string' },
|
|
472
|
-
severity: { type: 'string' },
|
|
473
|
-
blurs: { type: 'string' },
|
|
474
|
-
defaultSetting: { type: 'string' },
|
|
475
|
-
},
|
|
476
|
-
},
|
|
477
|
-
},
|
|
478
|
-
},
|
|
479
|
-
},
|
|
480
|
-
},
|
|
481
|
-
},
|
|
482
|
-
},
|
|
483
|
-
}, null, 2) + '\n');
|
|
484
|
-
writeFileSync(join(coreLexDir, 'createRecord.json'), JSON.stringify({
|
|
485
|
-
lexicon: 1,
|
|
486
|
-
id: 'dev.hatk.createRecord',
|
|
487
|
-
defs: {
|
|
488
|
-
main: {
|
|
489
|
-
type: 'procedure',
|
|
490
|
-
description: "Create a record via the user's PDS.",
|
|
491
|
-
input: {
|
|
492
|
-
encoding: 'application/json',
|
|
493
|
-
schema: {
|
|
494
|
-
type: 'object',
|
|
495
|
-
required: ['collection', 'repo', 'record'],
|
|
496
|
-
properties: {
|
|
497
|
-
collection: { type: 'string' },
|
|
498
|
-
repo: { type: 'string', format: 'did' },
|
|
499
|
-
record: { type: 'unknown' },
|
|
500
|
-
},
|
|
501
|
-
},
|
|
502
|
-
},
|
|
503
|
-
output: {
|
|
504
|
-
encoding: 'application/json',
|
|
505
|
-
schema: {
|
|
506
|
-
type: 'object',
|
|
507
|
-
properties: {
|
|
508
|
-
uri: { type: 'string', format: 'at-uri' },
|
|
509
|
-
cid: { type: 'string', format: 'cid' },
|
|
510
|
-
},
|
|
511
|
-
},
|
|
512
|
-
},
|
|
513
|
-
},
|
|
514
|
-
},
|
|
515
|
-
}, null, 2) + '\n');
|
|
516
|
-
writeFileSync(join(coreLexDir, 'deleteRecord.json'), JSON.stringify({
|
|
517
|
-
lexicon: 1,
|
|
518
|
-
id: 'dev.hatk.deleteRecord',
|
|
519
|
-
defs: {
|
|
520
|
-
main: {
|
|
521
|
-
type: 'procedure',
|
|
522
|
-
description: "Delete a record via the user's PDS.",
|
|
523
|
-
input: {
|
|
524
|
-
encoding: 'application/json',
|
|
525
|
-
schema: {
|
|
526
|
-
type: 'object',
|
|
527
|
-
required: ['collection', 'rkey'],
|
|
528
|
-
properties: {
|
|
529
|
-
collection: { type: 'string' },
|
|
530
|
-
rkey: { type: 'string' },
|
|
531
|
-
},
|
|
532
|
-
},
|
|
533
|
-
},
|
|
534
|
-
output: { encoding: 'application/json', schema: { type: 'object', properties: {} } },
|
|
535
|
-
},
|
|
536
|
-
},
|
|
537
|
-
}, null, 2) + '\n');
|
|
538
|
-
writeFileSync(join(coreLexDir, 'putRecord.json'), JSON.stringify({
|
|
539
|
-
lexicon: 1,
|
|
540
|
-
id: 'dev.hatk.putRecord',
|
|
541
|
-
defs: {
|
|
542
|
-
main: {
|
|
543
|
-
type: 'procedure',
|
|
544
|
-
description: "Create or update a record via the user's PDS.",
|
|
545
|
-
input: {
|
|
546
|
-
encoding: 'application/json',
|
|
547
|
-
schema: {
|
|
548
|
-
type: 'object',
|
|
549
|
-
required: ['collection', 'rkey', 'record'],
|
|
550
|
-
properties: {
|
|
551
|
-
collection: { type: 'string' },
|
|
552
|
-
rkey: { type: 'string' },
|
|
553
|
-
record: { type: 'unknown' },
|
|
554
|
-
repo: { type: 'string', format: 'did' },
|
|
555
|
-
},
|
|
556
|
-
},
|
|
557
|
-
},
|
|
558
|
-
output: {
|
|
559
|
-
encoding: 'application/json',
|
|
560
|
-
schema: {
|
|
561
|
-
type: 'object',
|
|
562
|
-
properties: {
|
|
563
|
-
uri: { type: 'string', format: 'at-uri' },
|
|
564
|
-
cid: { type: 'string', format: 'cid' },
|
|
565
|
-
},
|
|
566
|
-
},
|
|
567
|
-
},
|
|
568
|
-
},
|
|
569
|
-
},
|
|
570
|
-
}, null, 2) + '\n');
|
|
571
|
-
writeFileSync(join(coreLexDir, 'uploadBlob.json'), JSON.stringify({
|
|
572
|
-
lexicon: 1,
|
|
573
|
-
id: 'dev.hatk.uploadBlob',
|
|
574
|
-
defs: {
|
|
575
|
-
main: {
|
|
576
|
-
type: 'procedure',
|
|
577
|
-
description: "Upload a blob via the user's PDS.",
|
|
578
|
-
input: {
|
|
579
|
-
encoding: '*/*',
|
|
580
|
-
},
|
|
581
|
-
output: {
|
|
582
|
-
encoding: 'application/json',
|
|
583
|
-
schema: {
|
|
584
|
-
type: 'object',
|
|
585
|
-
required: ['blob'],
|
|
586
|
-
properties: {
|
|
587
|
-
blob: { type: 'blob' },
|
|
588
|
-
},
|
|
589
|
-
},
|
|
590
|
-
},
|
|
591
|
-
},
|
|
592
|
-
},
|
|
593
|
-
}, null, 2) + '\n');
|
|
594
|
-
writeFileSync(join(coreLexDir, 'getPreferences.json'), JSON.stringify({
|
|
595
|
-
lexicon: 1,
|
|
596
|
-
id: 'dev.hatk.getPreferences',
|
|
597
|
-
defs: {
|
|
598
|
-
main: {
|
|
599
|
-
type: 'query',
|
|
600
|
-
description: 'Get all preferences for the authenticated user.',
|
|
601
|
-
output: {
|
|
602
|
-
encoding: 'application/json',
|
|
603
|
-
schema: {
|
|
604
|
-
type: 'object',
|
|
605
|
-
properties: {
|
|
606
|
-
preferences: { type: 'unknown' },
|
|
607
|
-
},
|
|
608
|
-
},
|
|
609
|
-
},
|
|
610
|
-
},
|
|
611
|
-
},
|
|
612
|
-
}, null, 2) + '\n');
|
|
613
|
-
writeFileSync(join(coreLexDir, 'putPreference.json'), JSON.stringify({
|
|
614
|
-
lexicon: 1,
|
|
615
|
-
id: 'dev.hatk.putPreference',
|
|
616
|
-
defs: {
|
|
617
|
-
main: {
|
|
618
|
-
type: 'procedure',
|
|
619
|
-
description: 'Set a single preference by key.',
|
|
620
|
-
input: {
|
|
621
|
-
encoding: 'application/json',
|
|
622
|
-
schema: {
|
|
623
|
-
type: 'object',
|
|
624
|
-
required: ['key', 'value'],
|
|
625
|
-
properties: {
|
|
626
|
-
key: { type: 'string' },
|
|
627
|
-
value: { type: 'unknown' },
|
|
628
|
-
},
|
|
629
|
-
},
|
|
630
|
-
},
|
|
631
|
-
output: {
|
|
632
|
-
encoding: 'application/json',
|
|
633
|
-
schema: {
|
|
634
|
-
type: 'object',
|
|
635
|
-
properties: {},
|
|
636
|
-
},
|
|
637
|
-
},
|
|
638
|
-
},
|
|
639
|
-
},
|
|
640
|
-
}, null, 2) + '\n');
|
|
641
|
-
writeFileSync(join(coreLexDir, 'getFeed.json'), JSON.stringify({
|
|
642
|
-
lexicon: 1,
|
|
643
|
-
id: 'dev.hatk.getFeed',
|
|
644
|
-
defs: {
|
|
645
|
-
main: {
|
|
646
|
-
type: 'query',
|
|
647
|
-
description: 'Retrieve a named feed of items.',
|
|
648
|
-
parameters: {
|
|
649
|
-
type: 'params',
|
|
650
|
-
required: ['feed'],
|
|
651
|
-
properties: {
|
|
652
|
-
feed: { type: 'string', description: 'Feed name' },
|
|
653
|
-
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
|
|
654
|
-
cursor: { type: 'string' },
|
|
655
|
-
},
|
|
656
|
-
},
|
|
657
|
-
output: {
|
|
658
|
-
encoding: 'application/json',
|
|
659
|
-
schema: {
|
|
660
|
-
type: 'object',
|
|
661
|
-
required: ['items'],
|
|
662
|
-
properties: {
|
|
663
|
-
items: { type: 'array', items: { type: 'unknown' } },
|
|
664
|
-
cursor: { type: 'string' },
|
|
665
|
-
},
|
|
666
|
-
},
|
|
667
|
-
},
|
|
668
|
-
},
|
|
669
|
-
},
|
|
670
|
-
}, null, 2) + '\n');
|
|
671
|
-
writeFileSync(join(coreLexDir, 'getRecord.json'), JSON.stringify({
|
|
672
|
-
lexicon: 1,
|
|
673
|
-
id: 'dev.hatk.getRecord',
|
|
674
|
-
defs: {
|
|
675
|
-
main: {
|
|
676
|
-
type: 'query',
|
|
677
|
-
description: 'Fetch a single record by AT URI.',
|
|
678
|
-
parameters: {
|
|
679
|
-
type: 'params',
|
|
680
|
-
required: ['uri'],
|
|
681
|
-
properties: {
|
|
682
|
-
uri: { type: 'string', format: 'at-uri' },
|
|
683
|
-
},
|
|
684
|
-
},
|
|
685
|
-
output: {
|
|
686
|
-
encoding: 'application/json',
|
|
687
|
-
schema: {
|
|
688
|
-
type: 'object',
|
|
689
|
-
properties: {
|
|
690
|
-
record: { type: 'unknown' },
|
|
691
|
-
},
|
|
692
|
-
},
|
|
693
|
-
},
|
|
694
|
-
},
|
|
695
|
-
},
|
|
696
|
-
}, null, 2) + '\n');
|
|
697
|
-
writeFileSync(join(coreLexDir, 'getRecords.json'), JSON.stringify({
|
|
698
|
-
lexicon: 1,
|
|
699
|
-
id: 'dev.hatk.getRecords',
|
|
700
|
-
defs: {
|
|
701
|
-
main: {
|
|
702
|
-
type: 'query',
|
|
703
|
-
description: 'List records from a collection with optional filters.',
|
|
704
|
-
parameters: {
|
|
705
|
-
type: 'params',
|
|
706
|
-
required: ['collection'],
|
|
707
|
-
properties: {
|
|
708
|
-
collection: { type: 'string' },
|
|
709
|
-
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
|
710
|
-
cursor: { type: 'string' },
|
|
711
|
-
sort: { type: 'string' },
|
|
712
|
-
order: { type: 'string' },
|
|
713
|
-
},
|
|
714
|
-
},
|
|
715
|
-
output: {
|
|
716
|
-
encoding: 'application/json',
|
|
717
|
-
schema: {
|
|
718
|
-
type: 'object',
|
|
719
|
-
required: ['items'],
|
|
720
|
-
properties: {
|
|
721
|
-
items: { type: 'array', items: { type: 'unknown' } },
|
|
722
|
-
cursor: { type: 'string' },
|
|
723
|
-
},
|
|
724
|
-
},
|
|
725
|
-
},
|
|
726
|
-
},
|
|
727
|
-
},
|
|
728
|
-
}, null, 2) + '\n');
|
|
729
|
-
writeFileSync(join(coreLexDir, 'searchRecords.json'), JSON.stringify({
|
|
730
|
-
lexicon: 1,
|
|
731
|
-
id: 'dev.hatk.searchRecords',
|
|
732
|
-
defs: {
|
|
733
|
-
main: {
|
|
734
|
-
type: 'query',
|
|
735
|
-
description: 'Full-text search across a collection.',
|
|
736
|
-
parameters: {
|
|
737
|
-
type: 'params',
|
|
738
|
-
required: ['collection', 'q'],
|
|
739
|
-
properties: {
|
|
740
|
-
collection: { type: 'string' },
|
|
741
|
-
q: { type: 'string', description: 'Search query' },
|
|
742
|
-
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
|
743
|
-
cursor: { type: 'string' },
|
|
744
|
-
fuzzy: { type: 'boolean', default: true },
|
|
745
|
-
},
|
|
746
|
-
},
|
|
747
|
-
output: {
|
|
748
|
-
encoding: 'application/json',
|
|
749
|
-
schema: {
|
|
750
|
-
type: 'object',
|
|
751
|
-
required: ['items'],
|
|
752
|
-
properties: {
|
|
753
|
-
items: { type: 'array', items: { type: 'unknown' } },
|
|
754
|
-
cursor: { type: 'string' },
|
|
755
|
-
},
|
|
756
|
-
},
|
|
757
|
-
},
|
|
758
|
-
},
|
|
759
|
-
},
|
|
760
|
-
}, null, 2) + '\n');
|
|
761
|
-
writeFileSync(join(dir, 'seeds', 'seed.ts'), `import { seed } from '../hatk.generated.ts'
|
|
762
|
-
|
|
763
|
-
const { createAccount, createRecord } = seed()
|
|
764
|
-
|
|
765
|
-
const alice = await createAccount('alice.test')
|
|
766
|
-
|
|
767
|
-
// await createRecord(alice, 'your.collection.here', {
|
|
768
|
-
// field: 'value',
|
|
769
|
-
// }, { rkey: 'my-record' })
|
|
770
|
-
|
|
771
|
-
console.log('\\n[seed] Done!')
|
|
772
|
-
`);
|
|
773
|
-
writeFileSync(join(dir, 'docker-compose.yml'), `services:
|
|
774
|
-
plc:
|
|
775
|
-
build:
|
|
776
|
-
context: https://github.com/did-method-plc/did-method-plc.git
|
|
777
|
-
dockerfile: packages/server/Dockerfile
|
|
778
|
-
ports:
|
|
779
|
-
- '2582:2582'
|
|
780
|
-
environment:
|
|
781
|
-
- DATABASE_URL=postgres://plc:plc@postgres:5432/plc
|
|
782
|
-
- PORT=2582
|
|
783
|
-
command: ['dumb-init', 'node', '--enable-source-maps', '../dist/bin.js']
|
|
784
|
-
depends_on:
|
|
785
|
-
postgres:
|
|
786
|
-
condition: service_healthy
|
|
787
|
-
healthcheck:
|
|
788
|
-
test: ['CMD-SHELL', 'wget -q --spider http://localhost:2582/_health || exit 1']
|
|
789
|
-
interval: 2s
|
|
790
|
-
timeout: 5s
|
|
791
|
-
retries: 15
|
|
792
|
-
|
|
793
|
-
pds:
|
|
794
|
-
image: ghcr.io/bluesky-social/pds:latest
|
|
795
|
-
ports:
|
|
796
|
-
- '2583:2583'
|
|
797
|
-
environment:
|
|
798
|
-
- PDS_HOSTNAME=localhost
|
|
799
|
-
- PDS_PORT=2583
|
|
800
|
-
- PDS_DID_PLC_URL=http://plc:2582
|
|
801
|
-
- PDS_DATA_DIRECTORY=/pds
|
|
802
|
-
- PDS_BLOBSTORE_DISK_LOCATION=/pds/blobs
|
|
803
|
-
- PDS_JWT_SECRET=dev-jwt-secret
|
|
804
|
-
- PDS_ADMIN_PASSWORD=dev-admin
|
|
805
|
-
- PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
806
|
-
- PDS_INVITE_REQUIRED=false
|
|
807
|
-
- PDS_DEV_MODE=true
|
|
808
|
-
- LOG_ENABLED=true
|
|
809
|
-
volumes:
|
|
810
|
-
- pds_data:/pds
|
|
811
|
-
depends_on:
|
|
812
|
-
plc:
|
|
813
|
-
condition: service_healthy
|
|
814
|
-
healthcheck:
|
|
815
|
-
test: ['CMD-SHELL', 'wget -q --spider http://localhost:2583/xrpc/_health || exit 1']
|
|
816
|
-
interval: 2s
|
|
817
|
-
timeout: 5s
|
|
818
|
-
retries: 15
|
|
819
|
-
|
|
820
|
-
postgres:
|
|
821
|
-
image: postgres:16-alpine
|
|
822
|
-
environment:
|
|
823
|
-
- POSTGRES_USER=plc
|
|
824
|
-
- POSTGRES_PASSWORD=plc
|
|
825
|
-
- POSTGRES_DB=plc
|
|
826
|
-
volumes:
|
|
827
|
-
- plc_data:/var/lib/postgresql/data
|
|
828
|
-
healthcheck:
|
|
829
|
-
test: ['CMD-SHELL', 'pg_isready -U plc']
|
|
830
|
-
interval: 2s
|
|
831
|
-
timeout: 5s
|
|
832
|
-
retries: 10
|
|
833
|
-
|
|
834
|
-
volumes:
|
|
835
|
-
pds_data:
|
|
836
|
-
plc_data:
|
|
837
|
-
`);
|
|
838
|
-
writeFileSync(join(dir, '.dockerignore'), `node_modules
|
|
839
|
-
data
|
|
840
|
-
.svelte-kit
|
|
841
|
-
public
|
|
842
|
-
`);
|
|
843
|
-
writeFileSync(join(dir, 'Dockerfile'), `FROM node:25-slim
|
|
844
|
-
WORKDIR /app
|
|
845
|
-
COPY package.json package-lock.json ./
|
|
846
|
-
RUN npm ci
|
|
847
|
-
COPY . .
|
|
848
|
-
RUN node_modules/.bin/hatk build
|
|
849
|
-
RUN npm prune --omit=dev
|
|
850
|
-
EXPOSE 3000
|
|
851
|
-
CMD ["node", "--max-old-space-size=512", "node_modules/@hatk/hatk/dist/main.js", "config.yaml"]
|
|
852
|
-
`);
|
|
853
|
-
const pkgDeps = { '@hatk/oauth-client': '*', hatk: '*' };
|
|
854
|
-
const pkgDevDeps = {
|
|
855
|
-
'@playwright/test': '^1',
|
|
856
|
-
oxfmt: '^0.35.0',
|
|
857
|
-
oxlint: '^1',
|
|
858
|
-
typescript: '^5',
|
|
859
|
-
vite: '^6',
|
|
860
|
-
vitest: '^4',
|
|
861
|
-
'@types/node': '^22',
|
|
862
|
-
};
|
|
863
|
-
if (withSvelte) {
|
|
864
|
-
pkgDevDeps['@sveltejs/adapter-static'] = '^3';
|
|
865
|
-
pkgDevDeps['@sveltejs/kit'] = '^2';
|
|
866
|
-
pkgDevDeps['@sveltejs/vite-plugin-svelte'] = '^5';
|
|
867
|
-
pkgDevDeps['svelte'] = '^5';
|
|
868
|
-
pkgDevDeps['svelte-check'] = '^4';
|
|
869
|
-
}
|
|
870
|
-
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
|
871
|
-
name,
|
|
872
|
-
private: true,
|
|
873
|
-
type: 'module',
|
|
874
|
-
scripts: {
|
|
875
|
-
start: 'hatk start',
|
|
876
|
-
dev: 'hatk dev',
|
|
877
|
-
build: 'hatk build',
|
|
878
|
-
check: 'hatk check',
|
|
879
|
-
format: 'hatk format',
|
|
880
|
-
},
|
|
881
|
-
dependencies: pkgDeps,
|
|
882
|
-
devDependencies: pkgDevDeps,
|
|
883
|
-
}, null, 2) + '\n');
|
|
884
|
-
writeFileSync(join(dir, 'tsconfig.server.json'), JSON.stringify({
|
|
885
|
-
compilerOptions: {
|
|
886
|
-
target: 'ES2022',
|
|
887
|
-
module: 'Node16',
|
|
888
|
-
moduleResolution: 'Node16',
|
|
889
|
-
strict: true,
|
|
890
|
-
esModuleInterop: true,
|
|
891
|
-
skipLibCheck: true,
|
|
892
|
-
noEmit: true,
|
|
893
|
-
allowImportingTsExtensions: true,
|
|
894
|
-
resolveJsonModule: true,
|
|
895
|
-
},
|
|
896
|
-
include: ['feeds', 'xrpc', 'og', 'seeds', 'labels', 'jobs', 'setup', 'hatk.generated.ts'],
|
|
897
|
-
}, null, 2) + '\n');
|
|
898
|
-
writeFileSync(join(dir, 'playwright.config.ts'), `import { defineConfig } from '@playwright/test'
|
|
899
|
-
|
|
900
|
-
export default defineConfig({
|
|
901
|
-
testDir: 'test/browser',
|
|
902
|
-
use: { baseURL: 'http://127.0.0.1:3000' },
|
|
903
|
-
globalSetup: './test/browser/global-setup.ts',
|
|
904
|
-
})
|
|
905
|
-
`);
|
|
906
|
-
writeFileSync(join(dir, 'test/browser/global-setup.ts'), `import { execSync } from 'node:child_process'
|
|
907
|
-
import { existsSync } from 'node:fs'
|
|
908
|
-
|
|
909
|
-
export default function globalSetup() {
|
|
910
|
-
if (existsSync('src/app.html')) {
|
|
911
|
-
execSync('npx vite build', { stdio: 'inherit' })
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
`);
|
|
915
|
-
writeFileSync(join(dir, '.gitignore'), `node_modules/
|
|
916
|
-
*.db
|
|
917
|
-
data/
|
|
918
|
-
test-results/
|
|
919
|
-
.svelte-kit/
|
|
920
|
-
.DS_Store
|
|
921
|
-
public/
|
|
922
|
-
`);
|
|
923
|
-
writeFileSync(join(dir, '.oxlintrc.json'), `{
|
|
924
|
-
"ignorePatterns": ["public", "data", ".svelte-kit", "hatk.generated.ts"]
|
|
925
|
-
}
|
|
926
|
-
`);
|
|
927
|
-
writeFileSync(join(dir, '.oxfmtrc.json'), `{
|
|
928
|
-
"semi": false,
|
|
929
|
-
"singleQuote": true,
|
|
930
|
-
"trailingComma": "all",
|
|
931
|
-
"printWidth": 120,
|
|
932
|
-
"tabWidth": 2,
|
|
933
|
-
"ignorePatterns": ["public", "data", ".svelte-kit", "hatk.generated.ts"]
|
|
934
|
-
}
|
|
935
|
-
`);
|
|
936
|
-
if (withSvelte) {
|
|
937
|
-
writeFileSync(join(dir, 'svelte.config.js'), `import adapter from '@sveltejs/adapter-static'
|
|
938
|
-
|
|
939
|
-
export default {
|
|
940
|
-
kit: {
|
|
941
|
-
adapter: adapter({
|
|
942
|
-
pages: 'public',
|
|
943
|
-
assets: 'public',
|
|
944
|
-
fallback: 'index.html',
|
|
945
|
-
}),
|
|
946
|
-
paths: { base: '' },
|
|
947
|
-
alias: {
|
|
948
|
-
$hatk: './hatk.generated.ts',
|
|
949
|
-
},
|
|
950
|
-
},
|
|
951
|
-
}
|
|
952
|
-
`);
|
|
953
|
-
writeFileSync(join(dir, 'vite.config.ts'), `import { sveltekit } from '@sveltejs/kit/vite'
|
|
954
|
-
import { hatk } from '@hatk/hatk/vite-plugin'
|
|
955
|
-
import { defineConfig } from 'vite'
|
|
956
|
-
|
|
957
|
-
export default defineConfig({
|
|
958
|
-
plugins: [sveltekit(), hatk()],
|
|
959
|
-
})
|
|
960
|
-
`);
|
|
961
|
-
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
|
962
|
-
extends: './.svelte-kit/tsconfig.json',
|
|
963
|
-
compilerOptions: {
|
|
964
|
-
allowJs: true,
|
|
965
|
-
checkJs: false,
|
|
966
|
-
esModuleInterop: true,
|
|
967
|
-
forceConsistentCasingInFileNames: true,
|
|
968
|
-
resolveJsonModule: true,
|
|
969
|
-
skipLibCheck: true,
|
|
970
|
-
sourceMap: true,
|
|
971
|
-
strict: true,
|
|
972
|
-
moduleResolution: 'bundler',
|
|
973
|
-
allowImportingTsExtensions: true,
|
|
974
|
-
},
|
|
975
|
-
}, null, 2) + '\n');
|
|
976
|
-
writeFileSync(join(dir, 'src/app.html'), `<!doctype html>
|
|
977
|
-
<html lang="en">
|
|
978
|
-
<head>
|
|
979
|
-
<meta charset="utf-8" />
|
|
980
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
981
|
-
<meta name="description" content="${name}" />
|
|
982
|
-
<title>${name}</title>
|
|
983
|
-
%sveltekit.head%
|
|
984
|
-
</head>
|
|
985
|
-
<body data-sveltekit-preload-data="hover">
|
|
986
|
-
<div style="display: contents">%sveltekit.body%</div>
|
|
987
|
-
</body>
|
|
988
|
-
</html>
|
|
989
|
-
`);
|
|
990
|
-
writeFileSync(join(dir, 'src/app.css'), `*,
|
|
991
|
-
*::before,
|
|
992
|
-
*::after {
|
|
993
|
-
box-sizing: border-box;
|
|
994
|
-
margin: 0;
|
|
995
|
-
padding: 0;
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
:root {
|
|
999
|
-
--bg-root: #080b12;
|
|
1000
|
-
--bg-surface: #0f1419;
|
|
1001
|
-
--bg-elevated: #161d27;
|
|
1002
|
-
--bg-hover: #1c2633;
|
|
1003
|
-
--border: #1e293b;
|
|
1004
|
-
--teal: #14b8a6;
|
|
1005
|
-
--text-primary: #e2e8f0;
|
|
1006
|
-
--text-secondary: #94a3b8;
|
|
1007
|
-
--text-muted: #64748b;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
html {
|
|
1011
|
-
background: var(--bg-root);
|
|
1012
|
-
color: var(--text-primary);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
body {
|
|
1016
|
-
font-family: -apple-system, system-ui, sans-serif;
|
|
1017
|
-
font-size: 15px;
|
|
1018
|
-
line-height: 1.5;
|
|
1019
|
-
min-height: 100vh;
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
a {
|
|
1023
|
-
color: inherit;
|
|
1024
|
-
text-decoration: none;
|
|
1025
|
-
}
|
|
1026
|
-
`);
|
|
1027
|
-
writeFileSync(join(dir, 'src/routes/+layout.svelte'), `<script lang="ts">
|
|
1028
|
-
import type { Snippet } from 'svelte'
|
|
1029
|
-
import '../app.css'
|
|
1030
|
-
|
|
1031
|
-
let { children }: { children: Snippet } = $props()
|
|
1032
|
-
</script>
|
|
1033
|
-
|
|
1034
|
-
{@render children()}
|
|
1035
|
-
`);
|
|
1036
|
-
writeFileSync(join(dir, 'src/routes/+page.svelte'), `<h1>${name}</h1>
|
|
1037
|
-
<p>Your hatk server is running.</p>
|
|
1038
|
-
`);
|
|
1039
|
-
writeFileSync(join(dir, 'src/error.html'), `<!doctype html>
|
|
1040
|
-
<html lang="en">
|
|
1041
|
-
<head>
|
|
1042
|
-
<meta charset="utf-8" />
|
|
1043
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1044
|
-
<title>%sveltekit.error.message% — ${name}</title>
|
|
1045
|
-
<style>
|
|
1046
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1047
|
-
body {
|
|
1048
|
-
font-family: -apple-system, system-ui, sans-serif;
|
|
1049
|
-
background: #080b12; color: #e2e8f0;
|
|
1050
|
-
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
1051
|
-
}
|
|
1052
|
-
.error-page { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 8px; padding: 24px; }
|
|
1053
|
-
.error-code { font-size: 72px; font-weight: 800; color: #14b8a6; line-height: 1; }
|
|
1054
|
-
.error-title { font-size: 24px; font-weight: 800; }
|
|
1055
|
-
.error-link {
|
|
1056
|
-
margin-top: 16px; padding: 10px 24px; background: #14b8a6; color: #000;
|
|
1057
|
-
border-radius: 20px; font-weight: 600; font-size: 14px; text-decoration: none;
|
|
1058
|
-
}
|
|
1059
|
-
</style>
|
|
1060
|
-
</head>
|
|
1061
|
-
<body>
|
|
1062
|
-
<div class="error-page">
|
|
1063
|
-
<span class="error-code">%sveltekit.status%</span>
|
|
1064
|
-
<h1 class="error-title">%sveltekit.error.message%</h1>
|
|
1065
|
-
<a href="/" class="error-link">Back to home</a>
|
|
1066
|
-
</div>
|
|
1067
|
-
</body>
|
|
1068
|
-
</html>
|
|
1069
|
-
`);
|
|
1070
|
-
writeFileSync(join(dir, 'src/routes/+error.svelte'), `<script lang="ts">
|
|
1071
|
-
import { page } from '$app/state'
|
|
1072
|
-
</script>
|
|
1073
|
-
|
|
1074
|
-
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 80vh; gap: 8px;">
|
|
1075
|
-
<span style="font-size: 72px; font-weight: 800; color: var(--teal);">{page.status}</span>
|
|
1076
|
-
<h1 style="font-size: 24px; font-weight: 800;">{page.error?.message}</h1>
|
|
1077
|
-
<a href="/" style="margin-top: 16px; padding: 10px 24px; background: var(--teal); color: #000; border-radius: 20px; font-weight: 600; font-size: 14px;">Back to home</a>
|
|
1078
|
-
</div>
|
|
1079
|
-
`);
|
|
1080
|
-
}
|
|
1081
|
-
console.log(`Created ${name}/`);
|
|
1082
|
-
console.log(` config.yaml`);
|
|
1083
|
-
console.log(` lexicons/ — lexicon JSON files (core + your own)`);
|
|
1084
|
-
console.log(` feeds/ — feed generators`);
|
|
1085
|
-
console.log(` xrpc/ — XRPC method handlers`);
|
|
1086
|
-
console.log(` og/ — OpenGraph image routes`);
|
|
1087
|
-
console.log(` labels/ — label definitions + rules`);
|
|
1088
|
-
console.log(` jobs/ — periodic tasks`);
|
|
1089
|
-
console.log(` seeds/ — seed fixture data (hatk seed)`);
|
|
1090
|
-
console.log(` setup/ — boot-time setup scripts (run before server starts)`);
|
|
1091
|
-
console.log(` test/ — test files (hatk test)`);
|
|
1092
|
-
console.log(` public/ — static files`);
|
|
1093
|
-
console.log(` docker-compose.yml — local PDS for development`);
|
|
1094
|
-
console.log(` Dockerfile — production container`);
|
|
1095
|
-
if (withSvelte) {
|
|
1096
|
-
console.log(` src/ — SvelteKit frontend`);
|
|
1097
|
-
console.log(` svelte.config.js`);
|
|
1098
|
-
console.log(` vite.config.ts`);
|
|
1099
|
-
}
|
|
1100
|
-
// Generate types so the project is ready to go
|
|
1101
|
-
execSync('npx hatk generate types', { stdio: 'inherit', cwd: dir });
|
|
1102
|
-
if (withSvelte) {
|
|
1103
|
-
execSync('npx svelte-kit sync', { stdio: 'inherit', cwd: dir });
|
|
1104
|
-
}
|
|
192
|
+
console.error('`hatk new` has been removed. Create a new project with:');
|
|
193
|
+
console.error('');
|
|
194
|
+
console.error(' vp create github:hatk-dev/hatk-template-starter');
|
|
195
|
+
console.error('');
|
|
196
|
+
console.error('Install vp first: curl -fsSL https://vite.plus | bash');
|
|
197
|
+
process.exit(1);
|
|
1105
198
|
}
|
|
1106
199
|
else if (command === 'generate') {
|
|
1107
200
|
const type = args[1];
|
|
@@ -1126,6 +219,21 @@ else if (command === 'generate') {
|
|
|
1126
219
|
}
|
|
1127
220
|
}
|
|
1128
221
|
entries.sort((a, b) => a.nsid.localeCompare(b.nsid));
|
|
222
|
+
// Collect procedure nsids and blob-input nsids for client callXrpc
|
|
223
|
+
const procedureNsids = [];
|
|
224
|
+
const blobInputNsids = [];
|
|
225
|
+
for (const { nsid, defType } of entries) {
|
|
226
|
+
if (defType === 'procedure') {
|
|
227
|
+
const lex = lexicons.get(nsid);
|
|
228
|
+
const inputEncoding = lex?.defs?.main?.input?.encoding;
|
|
229
|
+
if (inputEncoding === '*/*') {
|
|
230
|
+
blobInputNsids.push(nsid);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
procedureNsids.push(nsid);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
1129
237
|
if (entries.length === 0) {
|
|
1130
238
|
console.error('No lexicons found');
|
|
1131
239
|
process.exit(1);
|
|
@@ -1173,7 +281,8 @@ else if (command === 'generate') {
|
|
|
1173
281
|
let out = '// Auto-generated from lexicons. Do not edit.\n';
|
|
1174
282
|
out += `import type { ${[...usedWrappers].sort().join(', ')}, LexServerParams, Checked, Prettify, StrictArg } from '@hatk/hatk/lex-types'\n`;
|
|
1175
283
|
out += `import type { XrpcContext } from '@hatk/hatk/xrpc'\n`;
|
|
1176
|
-
out += `import {
|
|
284
|
+
out += `import { callXrpc as _callXrpc } from '@hatk/hatk/xrpc'\n`;
|
|
285
|
+
out += `import { defineFeed as _defineFeed, type FeedResult, type FeedContext, type BaseContext, type Row } from '@hatk/hatk/feeds'\n`;
|
|
1177
286
|
out += `import { seed as _seed, type SeedOpts } from '@hatk/hatk/seed'\n`;
|
|
1178
287
|
// Emit ALL lexicons as `const ... = {...} as const` (including defs-only)
|
|
1179
288
|
out += `\n// ─── Lexicon Definitions ────────────────────────────────────────────\n\n`;
|
|
@@ -1329,8 +438,14 @@ else if (command === 'generate') {
|
|
|
1329
438
|
out += `}\n`;
|
|
1330
439
|
// Emit Ctx helper for typesafe XRPC handler contexts
|
|
1331
440
|
out += `\n// ─── XRPC Helpers ───────────────────────────────────────────────────\n\n`;
|
|
1332
|
-
out += `export type {
|
|
441
|
+
out += `export type { BaseContext, Row } from '@hatk/hatk/feeds'\n`;
|
|
1333
442
|
out += `export { InvalidRequestError, NotFoundError } from '@hatk/hatk/xrpc'\n`;
|
|
443
|
+
out += `export { defineSetup } from '@hatk/hatk/setup'\n`;
|
|
444
|
+
out += `export { defineHook } from '@hatk/hatk/hooks'\n`;
|
|
445
|
+
out += `export { defineLabel } from '@hatk/hatk/labels'\n`;
|
|
446
|
+
out += `export { defineClassifier } from '@hatk/hatk/classifiers'\n`;
|
|
447
|
+
out += `export { defineOG } from '@hatk/hatk/opengraph'\n`;
|
|
448
|
+
out += `export { defineRenderer } from '@hatk/hatk/renderer'\n`;
|
|
1334
449
|
out += `export type Ctx<K extends keyof XrpcSchema & keyof Registry> = XrpcContext<\n`;
|
|
1335
450
|
out += ` LexServerParams<Registry[K], Registry>,\n`;
|
|
1336
451
|
out += ` RecordRegistry,\n`;
|
|
@@ -1343,21 +458,29 @@ else if (command === 'generate') {
|
|
|
1343
458
|
out += ` nsid: K,\n`;
|
|
1344
459
|
out += ` handler: (ctx: Ctx<K> & { ok: <T extends OutputOf<K>>(value: StrictArg<T, OutputOf<K>>) => Checked<OutputOf<K>> }) => Promise<Checked<OutputOf<K>>>,\n`;
|
|
1345
460
|
out += `) {\n`;
|
|
1346
|
-
out += ` return { handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n`;
|
|
461
|
+
out += ` return { __type: 'query' as const, nsid, handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n`;
|
|
1347
462
|
out += `}\n\n`;
|
|
1348
463
|
out += `export function defineProcedure<K extends keyof XrpcSchema & string>(\n`;
|
|
1349
464
|
out += ` nsid: K,\n`;
|
|
1350
465
|
out += ` handler: (ctx: Ctx<K> & { ok: <T extends OutputOf<K>>(value: StrictArg<T, OutputOf<K>>) => Checked<OutputOf<K>> }) => Promise<Checked<OutputOf<K>>>,\n`;
|
|
1351
466
|
out += `) {\n`;
|
|
1352
|
-
out += ` return { handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n`;
|
|
467
|
+
out += ` return { __type: 'procedure' as const, nsid, handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n`;
|
|
468
|
+
out += `}\n\n`;
|
|
469
|
+
out += `// ─── Server-side XRPC Caller ────────────────────────────────────────\n\n`;
|
|
470
|
+
out += `type ExtractParams<T> = T extends { params: infer P } ? P : Record<string, unknown>\n`;
|
|
471
|
+
out += `export async function callXrpc<K extends keyof XrpcSchema & string>(\n`;
|
|
472
|
+
out += ` nsid: K,\n`;
|
|
473
|
+
out += ` params?: ExtractParams<XrpcSchema[K]>,\n`;
|
|
474
|
+
out += `): Promise<OutputOf<K>> {\n`;
|
|
475
|
+
out += ` return _callXrpc(nsid, params as any) as Promise<OutputOf<K>>\n`;
|
|
1353
476
|
out += `}\n\n`;
|
|
1354
477
|
out += `// ─── Feed & Seed Helpers ────────────────────────────────────────────\n\n`;
|
|
1355
478
|
out += `type FeedGenerate = (ctx: FeedContext & { ok: (value: FeedResult) => Checked<FeedResult> }) => Promise<Checked<FeedResult>>\n`;
|
|
1356
479
|
out += `export function defineFeed<K extends keyof RecordRegistry>(\n`;
|
|
1357
|
-
out += ` opts: { collection: K; view?: string; label: string; generate: FeedGenerate; hydrate?: (ctx:
|
|
480
|
+
out += ` opts: { collection: K; view?: string; label: string; generate: FeedGenerate; hydrate?: (ctx: BaseContext, items: Row<RecordRegistry[K]>[]) => Promise<unknown[]> }\n`;
|
|
1358
481
|
out += `): ReturnType<typeof _defineFeed>\n`;
|
|
1359
482
|
out += `export function defineFeed(\n`;
|
|
1360
|
-
out += ` opts: { collection?: never; view?: never; label: string; generate: FeedGenerate; hydrate: (ctx:
|
|
483
|
+
out += ` opts: { collection?: never; view?: never; label: string; generate: FeedGenerate; hydrate: (ctx: BaseContext, items: Row<unknown>[]) => Promise<unknown[]> }\n`;
|
|
1361
484
|
out += `): ReturnType<typeof _defineFeed>\n`;
|
|
1362
485
|
out += `export function defineFeed(opts: any) { return _defineFeed(opts) }\n`;
|
|
1363
486
|
out += `export function seed(opts?: SeedOpts) { return _seed<RecordRegistry>(opts) }\n`;
|
|
@@ -1380,7 +503,150 @@ else if (command === 'generate') {
|
|
|
1380
503
|
out = out.replace(/import type \{ ([^}]+) \} from '@hatk\/hatk\/lex-types'/, `import type { ${[...usedWrappers].sort().join(', ')}, LexServerParams, Checked, Prettify, StrictArg } from '@hatk/hatk/lex-types'`);
|
|
1381
504
|
}
|
|
1382
505
|
writeFileSync(outPath, out);
|
|
506
|
+
// Generate client-safe version (types + callXrpc only, no server module re-exports)
|
|
507
|
+
// Types use `export type` from main file (erased at compile time, no runtime import).
|
|
508
|
+
// callXrpc imports from @hatk/hatk/xrpc directly to avoid pulling in server deps.
|
|
509
|
+
let clientOut = '// Auto-generated client-safe subset. Do not edit.\n';
|
|
510
|
+
clientOut += `// Import this in app components instead of hatk.generated.ts\n`;
|
|
511
|
+
clientOut += `// to avoid pulling in server-only dependencies.\n`;
|
|
512
|
+
clientOut += `export type { XrpcSchema } from './hatk.generated.ts'\n`;
|
|
513
|
+
clientOut += `import type { XrpcSchema } from './hatk.generated.ts'\n`;
|
|
514
|
+
// Re-export all types
|
|
515
|
+
const typeExports = [];
|
|
516
|
+
for (const { nsid, defType } of entries) {
|
|
517
|
+
if (!defType)
|
|
518
|
+
continue;
|
|
519
|
+
if (nsid === 'dev.hatk.createRecord' || nsid === 'dev.hatk.deleteRecord' || nsid === 'dev.hatk.putRecord')
|
|
520
|
+
continue;
|
|
521
|
+
typeExports.push(capitalize(varNames.get(nsid)));
|
|
522
|
+
}
|
|
523
|
+
if (recordEntries.length > 0) {
|
|
524
|
+
typeExports.push('RecordRegistry', 'CreateRecord', 'DeleteRecord', 'PutRecord');
|
|
525
|
+
}
|
|
526
|
+
// Named defs (views, objects) — collect from emittedDefNames minus main types
|
|
527
|
+
const mainTypeNames = new Set(entries.filter((e) => e.defType).map((e) => capitalize(varNames.get(e.nsid))));
|
|
528
|
+
for (const name of emittedDefNames) {
|
|
529
|
+
if (!mainTypeNames.has(name) && !typeExports.includes(name)) {
|
|
530
|
+
typeExports.push(name);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (typeExports.length > 0) {
|
|
534
|
+
clientOut += `export type { ${typeExports.join(', ')} } from './hatk.generated.ts'\n`;
|
|
535
|
+
}
|
|
536
|
+
// Typed callXrpc — environment-aware:
|
|
537
|
+
// SSR: uses globalThis.__hatk_callXrpc bridge (direct handler invocation)
|
|
538
|
+
// Client: fetches via HTTP (GET for queries, POST for procedures, raw POST for blobs)
|
|
539
|
+
if (procedureNsids.length > 0) {
|
|
540
|
+
clientOut += `\nconst _procedures = new Set([${procedureNsids.map((n) => `'${n}'`).join(', ')}])\n`;
|
|
541
|
+
}
|
|
542
|
+
if (blobInputNsids.length > 0) {
|
|
543
|
+
clientOut += `const _blobInputs = new Set([${blobInputNsids.map((n) => `'${n}'`).join(', ')}])\n`;
|
|
544
|
+
}
|
|
545
|
+
clientOut += `\ntype CallArg<K extends keyof XrpcSchema> =\n`;
|
|
546
|
+
clientOut += ` XrpcSchema[K] extends { input: infer I } ? I :\n`;
|
|
547
|
+
clientOut += ` XrpcSchema[K] extends { params: infer P } ? P :\n`;
|
|
548
|
+
clientOut += ` Record<string, unknown>\n`;
|
|
549
|
+
clientOut += `type OutputOf<K extends keyof XrpcSchema> = XrpcSchema[K]['output']\n\n`;
|
|
550
|
+
clientOut += `export async function callXrpc<K extends keyof XrpcSchema & string>(\n`;
|
|
551
|
+
clientOut += ` nsid: K,\n`;
|
|
552
|
+
clientOut += ` arg?: CallArg<K>,\n`;
|
|
553
|
+
clientOut += ` customFetch?: typeof globalThis.fetch,\n`;
|
|
554
|
+
clientOut += `): Promise<OutputOf<K>> {\n`;
|
|
555
|
+
// Server-side bridge (skip when customFetch is provided — let SvelteKit's fetch handle it)
|
|
556
|
+
clientOut += ` if (typeof window === 'undefined' && !customFetch) {\n`;
|
|
557
|
+
clientOut += ` const bridge = (globalThis as any).__hatk_callXrpc\n`;
|
|
558
|
+
clientOut += ` if (!bridge) throw new Error('callXrpc: server bridge not available — is hatk initialized?')\n`;
|
|
559
|
+
if (procedureNsids.length > 0 || blobInputNsids.length > 0) {
|
|
560
|
+
const checks = [];
|
|
561
|
+
if (procedureNsids.length > 0)
|
|
562
|
+
checks.push('_procedures.has(nsid)');
|
|
563
|
+
if (blobInputNsids.length > 0)
|
|
564
|
+
checks.push('_blobInputs.has(nsid)');
|
|
565
|
+
clientOut += ` if (${checks.join(' || ')}) return bridge(nsid, {}, arg) as Promise<OutputOf<K>>\n`;
|
|
566
|
+
}
|
|
567
|
+
clientOut += ` return bridge(nsid, arg) as Promise<OutputOf<K>>\n`;
|
|
568
|
+
clientOut += ` }\n`;
|
|
569
|
+
// Client-side fetch (or server-side with customFetch for SSR deduplication)
|
|
570
|
+
clientOut += ` const _fetch = customFetch ?? globalThis.fetch\n`;
|
|
571
|
+
clientOut += ` // Use relative URL so SvelteKit's fetch can deduplicate server/client requests\n`;
|
|
572
|
+
clientOut += ` let path = \`/xrpc/\${nsid}\`\n`;
|
|
573
|
+
if (blobInputNsids.length > 0) {
|
|
574
|
+
clientOut += ` if (_blobInputs.has(nsid)) {\n`;
|
|
575
|
+
clientOut += ` const blob = arg as Blob | ArrayBuffer\n`;
|
|
576
|
+
clientOut += ` const ct = blob instanceof Blob ? blob.type : 'application/octet-stream'\n`;
|
|
577
|
+
clientOut += ` const res = await _fetch(path, { method: 'POST', headers: { 'Content-Type': ct }, body: blob })\n`;
|
|
578
|
+
clientOut += ` if (typeof window !== 'undefined' && res.status === 401) { const _b = await res.json().catch(() => ({})); const _h = _b.handle ?? getViewer()?.handle; window.location.href = _h ? \`/oauth/login?handle=\${encodeURIComponent(_h)}\` : '/oauth/login'; return new Promise(() => {}) as any }\n`;
|
|
579
|
+
clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n`;
|
|
580
|
+
clientOut += ` return res.json() as Promise<OutputOf<K>>\n`;
|
|
581
|
+
clientOut += ` }\n`;
|
|
582
|
+
}
|
|
583
|
+
if (procedureNsids.length > 0) {
|
|
584
|
+
clientOut += ` if (_procedures.has(nsid)) {\n`;
|
|
585
|
+
clientOut += ` const res = await _fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(arg) })\n`;
|
|
586
|
+
clientOut += ` if (typeof window !== 'undefined' && res.status === 401) { const _b = await res.json().catch(() => ({})); const _h = _b.handle ?? getViewer()?.handle; window.location.href = _h ? \`/oauth/login?handle=\${encodeURIComponent(_h)}\` : '/oauth/login'; return new Promise(() => {}) as any }\n`;
|
|
587
|
+
clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n`;
|
|
588
|
+
clientOut += ` return res.json() as Promise<OutputOf<K>>\n`;
|
|
589
|
+
clientOut += ` }\n`;
|
|
590
|
+
}
|
|
591
|
+
clientOut += ` const params = new URLSearchParams()\n`;
|
|
592
|
+
clientOut += ` for (const [k, v] of Object.entries(arg || {})) {\n`;
|
|
593
|
+
// An array goes out as a repeated key, the XRPC form: `?dids=a&dids=b`.
|
|
594
|
+
// Joined with commas it reaches the handler as one string.
|
|
595
|
+
clientOut += ` if (v == null) continue\n`;
|
|
596
|
+
clientOut += ` if (Array.isArray(v)) for (const item of v) params.append(k, String(item))\n`;
|
|
597
|
+
clientOut += ` else params.set(k, String(v))\n`;
|
|
598
|
+
clientOut += ` }\n`;
|
|
599
|
+
clientOut += ` const qs = params.toString()\n`;
|
|
600
|
+
clientOut += ` if (qs) path += \`?\${qs}\`\n`;
|
|
601
|
+
clientOut += ` const res = await _fetch(path)\n`;
|
|
602
|
+
clientOut += ` if (typeof window !== 'undefined' && res.status === 401) { const _b = await res.json().catch(() => ({})); const _h = _b.handle ?? getViewer()?.handle; window.location.href = _h ? \`/oauth/login?handle=\${encodeURIComponent(_h)}\` : '/oauth/login'; return new Promise(() => {}) as any }\n`;
|
|
603
|
+
clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n`;
|
|
604
|
+
clientOut += ` return res.json() as Promise<OutputOf<K>>\n`;
|
|
605
|
+
clientOut += `}\n`;
|
|
606
|
+
// getViewer — returns the viewer set by layout load (server) or $effect (client)
|
|
607
|
+
clientOut += `\nexport function getViewer(): { did: string; handle: string } | null {\n`;
|
|
608
|
+
clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n`;
|
|
609
|
+
clientOut += `}\n`;
|
|
610
|
+
// Auth helpers — login, logout, viewerDid
|
|
611
|
+
clientOut += `\n// ─── Auth Helpers ────────────────────────────────────────────────────\n\n`;
|
|
612
|
+
clientOut += `export async function login(handle: string): Promise<void> {\n`;
|
|
613
|
+
clientOut += ` const res = await fetch(\`/oauth/login?handle=\${encodeURIComponent(handle)}\`, { redirect: 'manual' })\n`;
|
|
614
|
+
clientOut += ` if (res.type === 'opaqueredirect') {\n`;
|
|
615
|
+
clientOut += ` window.location.href = \`/oauth/login?handle=\${encodeURIComponent(handle)}\`\n`;
|
|
616
|
+
clientOut += ` return\n`;
|
|
617
|
+
clientOut += ` }\n`;
|
|
618
|
+
clientOut += ` if (res.ok) return\n`;
|
|
619
|
+
clientOut += ` const body = await res.json().catch(() => ({ error: 'Login failed' }))\n`;
|
|
620
|
+
clientOut += ` throw new Error(body.error || 'Login failed')\n`;
|
|
621
|
+
clientOut += `}\n\n`;
|
|
622
|
+
clientOut += `export async function logout(): Promise<void> {\n`;
|
|
623
|
+
clientOut += ` ;(globalThis as any).__hatk_viewer = null\n`;
|
|
624
|
+
clientOut += ` await fetch('/auth/logout', { method: 'POST' }).catch(() => {})\n`;
|
|
625
|
+
clientOut += `}\n\n`;
|
|
626
|
+
clientOut += `export function viewerDid(): string | null {\n`;
|
|
627
|
+
clientOut += ` if (typeof window === 'undefined') return null\n`;
|
|
628
|
+
clientOut += ` const viewer = (globalThis as any).__hatk_viewer\n`;
|
|
629
|
+
clientOut += ` return viewer?.did ?? null\n`;
|
|
630
|
+
clientOut += `}\n\n`;
|
|
631
|
+
clientOut += `// Expose viewer for getViewer() bridge\n`;
|
|
632
|
+
clientOut += `;(globalThis as any).__hatk_auth = { viewerDid }\n`;
|
|
633
|
+
// parseViewer — server-side session cookie resolution for +layout.server.ts
|
|
634
|
+
clientOut += `\n// ─── Server Helpers ──────────────────────────────────────────────────\n\n`;
|
|
635
|
+
clientOut += `export async function parseViewer(cookies: { get(name: string): string | undefined }): Promise<{ did: string; handle?: string } | null> {\n`;
|
|
636
|
+
clientOut += ` const parseSessionCookie = (globalThis as any).__hatk_parseSessionCookie\n`;
|
|
637
|
+
clientOut += ` if (!parseSessionCookie) return null\n`;
|
|
638
|
+
clientOut += ` const cookieValue = cookies.get('__hatk_session')\n`;
|
|
639
|
+
clientOut += ` if (!cookieValue) return null\n`;
|
|
640
|
+
clientOut += ` try {\n`;
|
|
641
|
+
clientOut += ` const request = new Request('http://localhost', { headers: { cookie: \`__hatk_session=\${cookieValue}\` } })\n`;
|
|
642
|
+
clientOut += ` const viewer = await parseSessionCookie(request)\n`;
|
|
643
|
+
clientOut += ` if (viewer) (globalThis as any).__hatk_viewer = viewer\n`;
|
|
644
|
+
clientOut += ` return viewer\n`;
|
|
645
|
+
clientOut += ` } catch { return null }\n`;
|
|
646
|
+
clientOut += `}\n`;
|
|
647
|
+
writeFileSync('./hatk.generated.client.ts', clientOut);
|
|
1383
648
|
console.log(`Generated ${outPath} with ${entries.length} types: ${entries.map((e) => capitalize(varNames.get(e.nsid))).join(', ')}`);
|
|
649
|
+
console.log(`Generated ./hatk.generated.client.ts (client-safe subset)`);
|
|
1384
650
|
}
|
|
1385
651
|
else if (lexiconTemplates[type]) {
|
|
1386
652
|
const nsid = args[2];
|
|
@@ -1408,18 +674,9 @@ else if (command === 'generate') {
|
|
|
1408
674
|
process.exit(1);
|
|
1409
675
|
}
|
|
1410
676
|
const baseDir = dirs[type];
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
const parts = name.split('.');
|
|
1415
|
-
const subDir = join(baseDir, ...parts.slice(0, -1));
|
|
1416
|
-
mkdirSync(subDir, { recursive: true });
|
|
1417
|
-
filePath = join(subDir, `${parts[parts.length - 1]}.ts`);
|
|
1418
|
-
}
|
|
1419
|
-
else {
|
|
1420
|
-
mkdirSync(baseDir, { recursive: true });
|
|
1421
|
-
filePath = join(baseDir, `${name}.ts`);
|
|
1422
|
-
}
|
|
677
|
+
mkdirSync(baseDir, { recursive: true });
|
|
678
|
+
const fileName = type === 'xrpc' ? name.split('.').pop() : name;
|
|
679
|
+
const filePath = join(baseDir, `${fileName}.ts`);
|
|
1423
680
|
if (existsSync(filePath)) {
|
|
1424
681
|
console.error(`${filePath} already exists`);
|
|
1425
682
|
process.exit(1);
|
|
@@ -1429,7 +686,7 @@ else if (command === 'generate') {
|
|
|
1429
686
|
// Scaffold test file if template exists
|
|
1430
687
|
const testTemplate = testTemplates[type];
|
|
1431
688
|
if (testTemplate) {
|
|
1432
|
-
const testDir =
|
|
689
|
+
const testDir = 'test/server';
|
|
1433
690
|
mkdirSync(testDir, { recursive: true });
|
|
1434
691
|
const testName = type === 'xrpc' ? name.split('.').pop() : name;
|
|
1435
692
|
const testPath = join(testDir, `${testName}.test.ts`);
|
|
@@ -1448,18 +705,9 @@ else if (command === 'destroy') {
|
|
|
1448
705
|
process.exit(1);
|
|
1449
706
|
}
|
|
1450
707
|
const baseDir = dirs[type];
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
const leaf = parts[parts.length - 1];
|
|
1455
|
-
const subDir = join(baseDir, ...parts.slice(0, -1));
|
|
1456
|
-
tsPath = join(subDir, `${leaf}.ts`);
|
|
1457
|
-
jsPath = join(subDir, `${leaf}.js`);
|
|
1458
|
-
}
|
|
1459
|
-
else {
|
|
1460
|
-
tsPath = join(baseDir, `${name}.ts`);
|
|
1461
|
-
jsPath = join(baseDir, `${name}.js`);
|
|
1462
|
-
}
|
|
708
|
+
const fileName = type === 'xrpc' ? name.split('.').pop() : name;
|
|
709
|
+
const tsPath = join(baseDir, `${fileName}.ts`);
|
|
710
|
+
const jsPath = join(baseDir, `${fileName}.js`);
|
|
1463
711
|
const filePath = existsSync(tsPath) ? tsPath : existsSync(jsPath) ? jsPath : null;
|
|
1464
712
|
if (!filePath) {
|
|
1465
713
|
console.error(`No file found for ${type} "${name}"`);
|
|
@@ -1468,7 +716,7 @@ else if (command === 'destroy') {
|
|
|
1468
716
|
unlinkSync(filePath);
|
|
1469
717
|
console.log(`Removed ${filePath}`);
|
|
1470
718
|
// Clean up test file
|
|
1471
|
-
const testDir =
|
|
719
|
+
const testDir = 'test/server';
|
|
1472
720
|
const testName = type === 'xrpc' ? name.split('.').pop() : name;
|
|
1473
721
|
const testFile = join(testDir, `${testName}.test.ts`);
|
|
1474
722
|
if (existsSync(testFile)) {
|
|
@@ -1482,25 +730,14 @@ else if (command === 'destroy') {
|
|
|
1482
730
|
else if (command === 'dev') {
|
|
1483
731
|
await ensurePds();
|
|
1484
732
|
runSeed();
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
execSync('npx vite dev', { stdio: 'inherit', cwd: process.cwd() });
|
|
1489
|
-
}
|
|
1490
|
-
else {
|
|
1491
|
-
// No frontend — just run the hatk server directly
|
|
1492
|
-
const mainPath = resolve(import.meta.dirname, 'main.js');
|
|
1493
|
-
execSync(`npx tsx ${mainPath} config.yaml`, {
|
|
1494
|
-
stdio: 'inherit',
|
|
1495
|
-
cwd: process.cwd(),
|
|
1496
|
-
env: { ...process.env, DEV_MODE: '1' },
|
|
1497
|
-
});
|
|
1498
|
-
}
|
|
733
|
+
if (existsSync(resolve('vite.config.ts')) || existsSync(resolve('vite.config.js'))) {
|
|
734
|
+
// Vite project — vite dev starts the hatk server via the plugin
|
|
735
|
+
await spawnForward('npx', ['vite', 'dev']);
|
|
1499
736
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
737
|
+
else {
|
|
738
|
+
// No frontend — just run the hatk server directly
|
|
739
|
+
const mainPath = resolve(import.meta.dirname, 'main.js');
|
|
740
|
+
await spawnForward('npx', ['tsx', mainPath, 'hatk.config.ts'], { DEV_MODE: '1' });
|
|
1504
741
|
}
|
|
1505
742
|
}
|
|
1506
743
|
else if (command === 'format' || command === 'fmt') {
|
|
@@ -1520,9 +757,9 @@ else if (command === 'build') {
|
|
|
1520
757
|
}
|
|
1521
758
|
}
|
|
1522
759
|
else if (command === 'reset') {
|
|
1523
|
-
const config = loadConfig(resolve('config.
|
|
760
|
+
const config = await loadConfig(resolve('hatk.config.ts'));
|
|
1524
761
|
if (config.database !== ':memory:') {
|
|
1525
|
-
for (const suffix of ['', '.wal']) {
|
|
762
|
+
for (const suffix of ['', '.wal', '-shm', '-wal']) {
|
|
1526
763
|
const file = config.database + suffix;
|
|
1527
764
|
if (existsSync(file)) {
|
|
1528
765
|
unlinkSync(file);
|
|
@@ -1679,41 +916,9 @@ else if (command === 'resolve') {
|
|
|
1679
916
|
console.log(`\nResolved ${resolved.size} lexicon(s). Regenerating types...`);
|
|
1680
917
|
execSync('npx hatk generate types', { stdio: 'inherit', cwd: process.cwd() });
|
|
1681
918
|
}
|
|
1682
|
-
else if (command === 'schema') {
|
|
1683
|
-
const config = loadConfig(resolve('config.yaml'));
|
|
1684
|
-
if (config.database === ':memory:') {
|
|
1685
|
-
console.error('No database file configured (database is :memory:)');
|
|
1686
|
-
process.exit(1);
|
|
1687
|
-
}
|
|
1688
|
-
if (!existsSync(config.database)) {
|
|
1689
|
-
console.error(`Database not found: ${config.database}`);
|
|
1690
|
-
console.error('Run "hatk dev" first to create it.');
|
|
1691
|
-
process.exit(1);
|
|
1692
|
-
}
|
|
1693
|
-
const { DuckDBInstance } = await import('@duckdb/node-api');
|
|
1694
|
-
const instance = await DuckDBInstance.create(config.database);
|
|
1695
|
-
const con = await instance.connect();
|
|
1696
|
-
const tables = (await (await con.runAndReadAll(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name`)).getRowObjects());
|
|
1697
|
-
for (const { table_name } of tables) {
|
|
1698
|
-
console.log(`"${table_name}"`);
|
|
1699
|
-
const cols = (await (await con.runAndReadAll(`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = '${table_name}' ORDER BY ordinal_position`)).getRowObjects());
|
|
1700
|
-
for (const col of cols) {
|
|
1701
|
-
const nullable = col.is_nullable === 'YES' ? '' : ' NOT NULL';
|
|
1702
|
-
console.log(` ${col.column_name.padEnd(20)} ${col.data_type}${nullable}`);
|
|
1703
|
-
}
|
|
1704
|
-
console.log();
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
919
|
else if (command === 'start') {
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
execSync(`npx tsx ${mainPath} config.yaml`, { stdio: 'inherit', cwd: process.cwd() });
|
|
1711
|
-
}
|
|
1712
|
-
catch (e) {
|
|
1713
|
-
if (e.signal === 'SIGINT' || e.signal === 'SIGTERM')
|
|
1714
|
-
process.exit(0);
|
|
1715
|
-
throw e;
|
|
1716
|
-
}
|
|
920
|
+
const mainPath = resolve(import.meta.dirname, 'main.js');
|
|
921
|
+
await spawnForward('npx', ['tsx', mainPath, 'hatk.config.ts']);
|
|
1717
922
|
}
|
|
1718
923
|
else {
|
|
1719
924
|
usage();
|