create-drobek-module 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +38 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +53 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +91 -0
- package/package.json +43 -0
- package/template/README.md +57 -0
- package/template/SKILL.md +88 -0
- package/template/_gitignore +3 -0
- package/template/migrations/0000_init.sql +13 -0
- package/template/migrations/meta/_journal.json +13 -0
- package/template/package.json +44 -0
- package/template/src/index.test.ts +90 -0
- package/template/src/index.ts +111 -0
- package/template/src/schema.ts +18 -0
- package/template/src/sdk.ts +20 -0
- package/template/src/skill.test.ts +15 -0
- package/template/tsconfig.build.json +20 -0
- package/template/tsconfig.json +17 -0
- package/template/vitest.config.ts +6 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{package}} — a drobek platform module (module contract ^1.1).
|
|
3
|
+
*
|
|
4
|
+
* DROBEK_MODULES=…,{{entry}}
|
|
5
|
+
*
|
|
6
|
+
* GET /__drobek/v1/{{module}}/items → { items, upstream } (newest first, at most 100)
|
|
7
|
+
* POST /__drobek/v1/{{module}}/items → the new item ({ title }, rate-limited per visitor IP)
|
|
8
|
+
* drobek.{{module}}.list() / add(title)
|
|
9
|
+
* config { write, maxItems } — opening `write` to everyone needs the owner's OK.
|
|
10
|
+
*
|
|
11
|
+
* docs/MODULES.md in the drobek repository is the contract; SKILL.md is what
|
|
12
|
+
* the agent reads (`skill_info('{{module}}')`).
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { count, desc, eq } from 'drizzle-orm';
|
|
17
|
+
import { ModuleError, defineModule, z, type ModuleContext } from '@drobek/modules';
|
|
18
|
+
import { items } from './schema.js';
|
|
19
|
+
|
|
20
|
+
const here = (rel: string) => fileURLToPath(new URL(rel, import.meta.url));
|
|
21
|
+
|
|
22
|
+
/** The SDK entry next to this file: dist/sdk.js when built, src/sdk.ts in a source checkout. */
|
|
23
|
+
const sdkEntry = existsSync(here('./sdk.js')) ? here('./sdk.js') : here('./sdk.ts');
|
|
24
|
+
|
|
25
|
+
export const config = z.object({
|
|
26
|
+
/** Who may add items: anyone, or end users signed in through the auth module. */
|
|
27
|
+
write: z.enum(['public', 'user']),
|
|
28
|
+
/** The most items one app keeps. */
|
|
29
|
+
maxItems: z.number().int().min(1).max(100_000),
|
|
30
|
+
});
|
|
31
|
+
export type Config = z.infer<typeof config>;
|
|
32
|
+
|
|
33
|
+
/** What `drobek.{{module}}` looks like to the app (sdk.d.ts); src/sdk.ts implements it. */
|
|
34
|
+
const SDK_TYPES = `
|
|
35
|
+
export interface Item {
|
|
36
|
+
id: number;
|
|
37
|
+
title: string;
|
|
38
|
+
created_at: string;
|
|
39
|
+
}
|
|
40
|
+
export interface Api {
|
|
41
|
+
/** The app's items, newest first (at most 100); upstream: the owner set {{MODULE}}_API_KEY */
|
|
42
|
+
list(): Promise<{ items: Item[]; upstream: boolean }>;
|
|
43
|
+
/** title: 1–200 characters; rate-limited ({{MODULE}}_ADDS_PER_MINUTE per visitor IP per minute) */
|
|
44
|
+
add(title: string): Promise<Item>;
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const itemView = (row: typeof items.$inferSelect) => ({ id: row.id, title: row.title, created_at: row.createdAt.toISOString() });
|
|
49
|
+
|
|
50
|
+
async function itemCount(db: ModuleContext['db'], appId: string): Promise<number> {
|
|
51
|
+
const [row] = await db.select({ n: count() }).from(items).where(eq(items.appId, appId));
|
|
52
|
+
return Number(row?.n ?? 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default defineModule<Config>({
|
|
56
|
+
name: '{{module}}',
|
|
57
|
+
version: '0.1.0',
|
|
58
|
+
contract: '^1.1',
|
|
59
|
+
skill: {
|
|
60
|
+
useWhen: 'the app keeps a list of items on the server (the {{module}} module)',
|
|
61
|
+
markdown: readFileSync(here('../SKILL.md'), 'utf8'),
|
|
62
|
+
},
|
|
63
|
+
configSchema: config,
|
|
64
|
+
configDefaults: { write: 'public', maxItems: 1000 },
|
|
65
|
+
// A change that widens who can write waits for the app owner's confirmation.
|
|
66
|
+
confirmRequired(before, after) {
|
|
67
|
+
return before.write !== after.write && after.write === 'public' ? ['write: anyone may add items'] : [];
|
|
68
|
+
},
|
|
69
|
+
// Names only: the owner sets the value in the dashboard; ctx.secrets.get reads it.
|
|
70
|
+
secrets: [{ name: '{{MODULE}}_API_KEY', description: 'The key of the upstream API this module calls (optional)' }],
|
|
71
|
+
// The operator (or the limits provider, per workspace) may change the default.
|
|
72
|
+
limits: [{ env: '{{MODULE}}_ADDS_PER_MINUTE', default: 30, meaning: 'items one visitor IP may add per minute' }],
|
|
73
|
+
// Every code a route throws besides the core ones (a ModuleError with another code is a 500).
|
|
74
|
+
errors: [
|
|
75
|
+
{
|
|
76
|
+
code: '{{module}}_full',
|
|
77
|
+
meaning: 'HTTP 409. The app already keeps `maxItems` items (`details.max`).',
|
|
78
|
+
fix: 'Tell the user the list is full; the app owner can raise maxItems with configure_module.',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
routes(r) {
|
|
82
|
+
r.get('/items', { rule: 'public' }, async (_req, ctx) => {
|
|
83
|
+
const rows = await ctx.db.select().from(items).where(eq(items.appId, ctx.app.id)).orderBy(desc(items.id)).limit(100);
|
|
84
|
+
// Call your upstream with the key here; the value never leaves the server.
|
|
85
|
+
const key = await ctx.secrets.get('{{MODULE}}_API_KEY');
|
|
86
|
+
return { items: rows.map(itemView), upstream: key !== null };
|
|
87
|
+
});
|
|
88
|
+
r.post(
|
|
89
|
+
'/items',
|
|
90
|
+
{
|
|
91
|
+
rule: (c) => c.write,
|
|
92
|
+
body: z.object({ title: z.string().trim().min(1).max(200) }),
|
|
93
|
+
rateLimit: { bucket: 'add', max: '{{MODULE}}_ADDS_PER_MINUTE', windowMs: 60_000, per: 'ip' },
|
|
94
|
+
maxBodyBytes: 4096,
|
|
95
|
+
},
|
|
96
|
+
async (req, ctx) => {
|
|
97
|
+
if ((await itemCount(ctx.db, ctx.app.id)) >= ctx.config.maxItems) {
|
|
98
|
+
throw new ModuleError('{{module}}_full', `This app keeps at most ${ctx.config.maxItems} items.`, {
|
|
99
|
+
status: 409,
|
|
100
|
+
details: { max: ctx.config.maxItems },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const [row] = await ctx.db.insert(items).values({ appId: ctx.app.id, title: req.body.title }).returning();
|
|
104
|
+
await ctx.audit('add', { id: row.id });
|
|
105
|
+
return itemView(row);
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
sdk: { entry: sdkEntry, types: SDK_TYPES },
|
|
110
|
+
migrations: { folder: here('../migrations') },
|
|
111
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module's own table, created by ../migrations (journal
|
|
3
|
+
* `__drizzle_migrations_mod_{{module}}`). Module tables are named
|
|
4
|
+
* `mod_{{module}}_*` and reference apps(id) with ON DELETE CASCADE, so
|
|
5
|
+
* deleting an app deletes its module data.
|
|
6
|
+
*/
|
|
7
|
+
import { bigserial, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
|
8
|
+
|
|
9
|
+
export const items = pgTable(
|
|
10
|
+
'mod_{{module}}_items',
|
|
11
|
+
{
|
|
12
|
+
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
|
13
|
+
appId: text('app_id').notNull(),
|
|
14
|
+
title: text('title').notNull(),
|
|
15
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
16
|
+
},
|
|
17
|
+
(t) => [index('mod_{{module}}_items_app_idx').on(t.appId)]
|
|
18
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser half of the module: the drobek server bundles it into
|
|
3
|
+
* `/__drobek/sdk.js` as `drobek.{{module}}`. It runs in the app's page — keep
|
|
4
|
+
* it dependency-free (type imports only). The declared types are SDK_TYPES
|
|
5
|
+
* in src/index.ts.
|
|
6
|
+
*/
|
|
7
|
+
import type { SdkCore } from '@drobek/modules';
|
|
8
|
+
|
|
9
|
+
export interface Item {
|
|
10
|
+
id: number;
|
|
11
|
+
title: string;
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function sdk(core: SdkCore) {
|
|
16
|
+
return {
|
|
17
|
+
list: () => core.request<{ items: Item[]; upstream: boolean }>('GET', '/items'),
|
|
18
|
+
add: (title: string) => core.request<Item>('POST', '/items', { body: { title } }),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SKILL.md is what an agent reads (`skill_info`). checkSkill runs the gate
|
|
3
|
+
* drobek runs on its built-in modules: the five sections, ≤ 150 lines, only
|
|
4
|
+
* real error codes, and every code block compiled + typechecked against this
|
|
5
|
+
* module's SDK types. `npm run check` runs this file alone.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { checkSkill, formatSkillIssue } from '@drobek/modules/testing';
|
|
9
|
+
import mod from './index.js';
|
|
10
|
+
|
|
11
|
+
describe('SKILL.md', () => {
|
|
12
|
+
it('passes checkSkill', async () => {
|
|
13
|
+
expect((await checkSkill(mod)).map(formatSkillIssue)).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": false,
|
|
15
|
+
"sourceMap": false,
|
|
16
|
+
"types": ["node"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["./src/**/*.ts"],
|
|
19
|
+
"exclude": ["./src/**/*.test.ts"]
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"verbatimModuleSyntax": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"noUnusedLocals": true,
|
|
13
|
+
"noUnusedParameters": true,
|
|
14
|
+
"types": ["node"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts"]
|
|
17
|
+
}
|