@microservices-sh/sdk-internal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -0
- package/src/index.d.ts +147 -0
- package/src/index.js +1588 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
import {
|
|
2
|
+
composeApp as composeContractApp,
|
|
3
|
+
inspectModule as inspectContractModule,
|
|
4
|
+
inspectTemplate as inspectContractTemplate,
|
|
5
|
+
listModules as listContractModules,
|
|
6
|
+
listTemplates as listContractTemplates,
|
|
7
|
+
} from "@microservices-sh/module-contract";
|
|
8
|
+
|
|
9
|
+
function requestId() {
|
|
10
|
+
return `local_${Date.now().toString(36)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ok(data, warnings = []) {
|
|
14
|
+
return {
|
|
15
|
+
ok: true,
|
|
16
|
+
requestId: requestId(),
|
|
17
|
+
data,
|
|
18
|
+
warnings,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fail(error) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
requestId: requestId(),
|
|
26
|
+
error: {
|
|
27
|
+
code: error.code ?? "UNKNOWN_ERROR",
|
|
28
|
+
message: error.message,
|
|
29
|
+
remediation: error.remediation ?? "Inspect the command input and retry.",
|
|
30
|
+
details: error.details ?? {},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function capture(fn) {
|
|
36
|
+
try {
|
|
37
|
+
return ok(fn());
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return fail(error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function slugify(value) {
|
|
44
|
+
return String(value ?? "microservices-app")
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
47
|
+
.replace(/^-+|-+$/g, "")
|
|
48
|
+
.slice(0, 60) || "microservices-app";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function json(value) {
|
|
52
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PLANNED_MODULE_DOCS = Object.freeze([
|
|
56
|
+
{
|
|
57
|
+
id: "payment-stripe",
|
|
58
|
+
name: "Stripe Payment",
|
|
59
|
+
version: "0.1.0",
|
|
60
|
+
status: "planned",
|
|
61
|
+
class: "provider",
|
|
62
|
+
mount: "/payments",
|
|
63
|
+
summary:
|
|
64
|
+
"Full Stripe payment workflow: products, prices, checkout, payment links, webhooks, refunds, and payment events.",
|
|
65
|
+
requires: ["auth", "customer"],
|
|
66
|
+
permissions: ["payment.read", "payment.write", "payment.admin"],
|
|
67
|
+
approvalRisk: "high",
|
|
68
|
+
secrets: ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"],
|
|
69
|
+
resources: ["D1 payment tables", "Stripe webhook endpoint", "outbound fetch to api.stripe.com"],
|
|
70
|
+
hooks: ["beforeCheckoutCreate", "afterPaymentSucceeded", "beforeRefundCreate"],
|
|
71
|
+
events: ["payment.checkout_created", "payment.succeeded", "payment.refunded", "payment.failed"],
|
|
72
|
+
statusNote: "Planned provider module. MVP command should produce an install plan before code mutation.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "email",
|
|
76
|
+
name: "Email",
|
|
77
|
+
version: "0.1.0",
|
|
78
|
+
status: "planned",
|
|
79
|
+
class: "provider-capable core",
|
|
80
|
+
mount: "/emails",
|
|
81
|
+
summary: "Transactional email templates, provider adapters, delivery jobs, and delivery events.",
|
|
82
|
+
requires: [],
|
|
83
|
+
permissions: ["email.read", "email.write", "email.admin"],
|
|
84
|
+
approvalRisk: "high",
|
|
85
|
+
secrets: ["EMAIL_PROVIDER_API_KEY"],
|
|
86
|
+
resources: ["Queue for delivery jobs", "outbound fetch to provider API"],
|
|
87
|
+
hooks: ["renderEmailTemplate", "beforeEmailSend", "afterEmailDelivered"],
|
|
88
|
+
events: ["email.queued", "email.sent", "email.failed"],
|
|
89
|
+
statusNote: "Planned provider-capable module. Start with test mode and approval-gated sends.",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "audit-log",
|
|
93
|
+
name: "Audit Log",
|
|
94
|
+
version: "0.1.0",
|
|
95
|
+
status: "planned",
|
|
96
|
+
class: "core",
|
|
97
|
+
mount: "/audit",
|
|
98
|
+
summary: "Mutation audit trail and domain event recording with actor, action, resource, request id, and metadata.",
|
|
99
|
+
requires: [],
|
|
100
|
+
permissions: ["audit.read", "audit.export", "audit.admin"],
|
|
101
|
+
approvalRisk: "medium",
|
|
102
|
+
secrets: [],
|
|
103
|
+
resources: ["D1 audit_events table", "optional R2 export bucket"],
|
|
104
|
+
hooks: ["redactAuditPayload", "beforeAuditExport"],
|
|
105
|
+
events: ["audit.recorded", "audit.exported"],
|
|
106
|
+
statusNote: "Partly present in generated apps as src/lib/audit.ts; full module packaging is planned.",
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
function docPathFor(moduleId) {
|
|
111
|
+
return `docs/modules/${moduleId}.md`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function availableModuleDocs() {
|
|
115
|
+
return listContractModules().map((summary) => {
|
|
116
|
+
const module = inspectContractModule(summary.id);
|
|
117
|
+
return {
|
|
118
|
+
id: module.id,
|
|
119
|
+
name: module.name,
|
|
120
|
+
version: module.version,
|
|
121
|
+
status: module.status,
|
|
122
|
+
class: module.category,
|
|
123
|
+
mount: module.runtime.mount,
|
|
124
|
+
docPath: docPathFor(module.id),
|
|
125
|
+
summary: module.summary,
|
|
126
|
+
requires: module.requires,
|
|
127
|
+
permissions: module.permissions,
|
|
128
|
+
approvalRisk: module.id === "auth" ? "high" : "medium",
|
|
129
|
+
secrets: module.id === "auth" ? ["SESSION_SECRET"] : [],
|
|
130
|
+
resources: module.storage.map((item) => item.toUpperCase()),
|
|
131
|
+
hooks: module.hooks.map((hook) => hook.name),
|
|
132
|
+
events: [...module.eventsEmitted, ...module.eventsConsumed],
|
|
133
|
+
statusNote: "Available in the generated MVP app.",
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function moduleCatalog() {
|
|
139
|
+
const modules = [...availableModuleDocs(), ...PLANNED_MODULE_DOCS].map((module) => ({
|
|
140
|
+
id: module.id,
|
|
141
|
+
name: module.name,
|
|
142
|
+
version: module.version,
|
|
143
|
+
status: module.status,
|
|
144
|
+
class: module.class,
|
|
145
|
+
mount: module.mount,
|
|
146
|
+
docPath: module.docPath ?? docPathFor(module.id),
|
|
147
|
+
summary: module.summary,
|
|
148
|
+
requires: module.requires,
|
|
149
|
+
permissions: module.permissions,
|
|
150
|
+
approvalRisk: module.approvalRisk,
|
|
151
|
+
secrets: module.secrets,
|
|
152
|
+
resources: module.resources,
|
|
153
|
+
hooks: module.hooks,
|
|
154
|
+
events: module.events,
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
schemaVersion: "2026-06-13",
|
|
159
|
+
description:
|
|
160
|
+
"Compact LLM-readable catalog for generated microservices.sh projects. Read the referenced Markdown docs before modifying modules.",
|
|
161
|
+
agentGuidance: {
|
|
162
|
+
preferDocsBeforeCodeChanges: true,
|
|
163
|
+
neverExposeSecretValues: true,
|
|
164
|
+
defaultCustomizationOrder: ["config", "hooks", "overlays", "fork"],
|
|
165
|
+
approvalGatedCategories: ["auth", "payment", "email", "pii", "webhook", "migration", "production_deploy", "delete"],
|
|
166
|
+
sourceOwnershipDefault: "user-owned repository with branch/PR or patch workflow",
|
|
167
|
+
},
|
|
168
|
+
modules,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function findCatalogModule(moduleId) {
|
|
173
|
+
const module = moduleCatalog().modules.find((candidate) => candidate.id === moduleId);
|
|
174
|
+
if (!module) {
|
|
175
|
+
const error = new Error(`Unknown module: ${moduleId}`);
|
|
176
|
+
error.code = "MODULE_NOT_FOUND";
|
|
177
|
+
error.remediation = "Run modules list --json and select a returned module id.";
|
|
178
|
+
error.details = { moduleId };
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
return module;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function moduleDocMarkdown(module) {
|
|
185
|
+
return `# ${module.name}
|
|
186
|
+
|
|
187
|
+
Status: ${module.status}
|
|
188
|
+
Module ID: \`${module.id}\`
|
|
189
|
+
Mount: \`${module.mount}\`
|
|
190
|
+
|
|
191
|
+
## Summary
|
|
192
|
+
${module.summary}
|
|
193
|
+
|
|
194
|
+
## Dependencies
|
|
195
|
+
${module.requires.length ? module.requires.map((item) => `- ${item}`).join("\n") : "- none"}
|
|
196
|
+
|
|
197
|
+
## Permissions
|
|
198
|
+
${module.permissions.map((item) => `- ${item}`).join("\n")}
|
|
199
|
+
|
|
200
|
+
## Secrets
|
|
201
|
+
${module.secrets.length ? module.secrets.map((item) => `- ${item}`).join("\n") : "- none"}
|
|
202
|
+
|
|
203
|
+
Agents may inspect secret names and configured/missing status. They must not request or print secret values.
|
|
204
|
+
|
|
205
|
+
## Resources
|
|
206
|
+
${module.resources.length ? module.resources.map((item) => `- ${item}`).join("\n") : "- none"}
|
|
207
|
+
|
|
208
|
+
## Hooks
|
|
209
|
+
${module.hooks.length ? module.hooks.map((item) => `- ${item}`).join("\n") : "- none"}
|
|
210
|
+
|
|
211
|
+
## Events
|
|
212
|
+
${module.events.length ? module.events.map((item) => `- ${item}`).join("\n") : "- none"}
|
|
213
|
+
|
|
214
|
+
## Approval Gate
|
|
215
|
+
Risk: ${module.approvalRisk}
|
|
216
|
+
|
|
217
|
+
Adding or changing auth, payment, email, webhook, migration, PII, or production deploy behavior requires explicit approval.
|
|
218
|
+
|
|
219
|
+
## Update Notes
|
|
220
|
+
Config and hook changes are expected to stay upgradeable. Overlays and forks require manual or agent-assisted merge review.
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function buildLlmGuide(composition) {
|
|
225
|
+
return `# ${composition.config.appName ?? composition.template.name} Agent Guide
|
|
226
|
+
|
|
227
|
+
This project was generated by microservices.sh.
|
|
228
|
+
|
|
229
|
+
## First Commands
|
|
230
|
+
\`\`\`bash
|
|
231
|
+
pnpm install
|
|
232
|
+
pnpm microservices modules list --json
|
|
233
|
+
pnpm microservices docs booking
|
|
234
|
+
pnpm microservices upgrade booking --plan --json
|
|
235
|
+
pnpm microservices check --json
|
|
236
|
+
pnpm dev
|
|
237
|
+
\`\`\`
|
|
238
|
+
|
|
239
|
+
## Agent Rules
|
|
240
|
+
1. Read \`microservices.lock.json\` before changing modules.
|
|
241
|
+
2. Read \`docs/modules/catalog.json\` and the relevant module docs before edits.
|
|
242
|
+
3. Prefer config and hooks before editing module internals.
|
|
243
|
+
4. Never ask the user to paste secret values into chat.
|
|
244
|
+
5. Treat payment, email, auth, PII, webhooks, migrations, and production deploys as approval-gated.
|
|
245
|
+
6. Run \`pnpm microservices check --json\` before preview or production deployment.
|
|
246
|
+
7. Run \`pnpm microservices upgrade <module-id> --plan --json\` before changing locked module versions.
|
|
247
|
+
|
|
248
|
+
## Current Modules
|
|
249
|
+
${composition.modules.map((module) => `- ${module.id}@${module.version} at ${module.runtime.mount}`).join("\n")}
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildMicroservicesConfig(composition) {
|
|
254
|
+
return json({
|
|
255
|
+
schemaVersion: "2026-06-13",
|
|
256
|
+
template: composition.template.id,
|
|
257
|
+
runtime: {
|
|
258
|
+
framework: "hono",
|
|
259
|
+
platform: "cloudflare-workers",
|
|
260
|
+
},
|
|
261
|
+
moduleDocs: "docs/modules/catalog.json",
|
|
262
|
+
lockfile: "microservices.lock.json",
|
|
263
|
+
managedCloudflare: {
|
|
264
|
+
dispatchNamespace: "microservices-sh",
|
|
265
|
+
previewDeploy: "approval-gated",
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildPackageJson(composition) {
|
|
271
|
+
const appSlug = slugify(composition.config.appSlug ?? composition.config.appName);
|
|
272
|
+
return json({
|
|
273
|
+
name: appSlug,
|
|
274
|
+
private: true,
|
|
275
|
+
version: "0.0.0",
|
|
276
|
+
type: "module",
|
|
277
|
+
scripts: {
|
|
278
|
+
dev: "wrangler dev",
|
|
279
|
+
deploy: "wrangler deploy",
|
|
280
|
+
"db:init": "wrangler d1 execute microservices_generated --local --file=./schema.sql",
|
|
281
|
+
microservices: "node scripts/microservices.js",
|
|
282
|
+
ms: "node scripts/microservices.js",
|
|
283
|
+
check: "node scripts/microservices.js check && tsc --noEmit",
|
|
284
|
+
typecheck: "tsc --noEmit",
|
|
285
|
+
},
|
|
286
|
+
dependencies: {
|
|
287
|
+
hono: "^4.6.14",
|
|
288
|
+
},
|
|
289
|
+
devDependencies: {
|
|
290
|
+
"@cloudflare/workers-types": "^4.20241218.0",
|
|
291
|
+
typescript: "^5.9.3",
|
|
292
|
+
wrangler: "^3.95.0",
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildWranglerJson(composition) {
|
|
298
|
+
const appSlug = slugify(composition.config.appSlug ?? composition.config.appName);
|
|
299
|
+
return json({
|
|
300
|
+
$schema: "node_modules/wrangler/config-schema.json",
|
|
301
|
+
name: appSlug,
|
|
302
|
+
main: "src/index.ts",
|
|
303
|
+
compatibility_date: "2026-06-01",
|
|
304
|
+
compatibility_flags: ["nodejs_compat"],
|
|
305
|
+
observability: { enabled: true },
|
|
306
|
+
d1_databases: [
|
|
307
|
+
{
|
|
308
|
+
binding: "DB",
|
|
309
|
+
database_name: "microservices_generated",
|
|
310
|
+
database_id: "REPLACE_WITH_D1_ID",
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
kv_namespaces: [
|
|
314
|
+
{
|
|
315
|
+
binding: "CACHE_KV",
|
|
316
|
+
id: "REPLACE_WITH_KV_ID",
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildSchemaSql() {
|
|
323
|
+
return `CREATE TABLE IF NOT EXISTS users (
|
|
324
|
+
id TEXT PRIMARY KEY,
|
|
325
|
+
email TEXT NOT NULL UNIQUE,
|
|
326
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
327
|
+
created_at TEXT NOT NULL
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
CREATE TABLE IF NOT EXISTS customers (
|
|
331
|
+
id TEXT PRIMARY KEY,
|
|
332
|
+
user_id TEXT,
|
|
333
|
+
name TEXT NOT NULL,
|
|
334
|
+
email TEXT NOT NULL,
|
|
335
|
+
phone TEXT,
|
|
336
|
+
notes TEXT,
|
|
337
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
338
|
+
created_at TEXT NOT NULL,
|
|
339
|
+
updated_at TEXT NOT NULL,
|
|
340
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
CREATE TABLE IF NOT EXISTS bookings (
|
|
344
|
+
id TEXT PRIMARY KEY,
|
|
345
|
+
customer_id TEXT NOT NULL,
|
|
346
|
+
service_type TEXT NOT NULL,
|
|
347
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
348
|
+
starts_at TEXT NOT NULL,
|
|
349
|
+
ends_at TEXT NOT NULL,
|
|
350
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
351
|
+
created_at TEXT NOT NULL,
|
|
352
|
+
updated_at TEXT NOT NULL,
|
|
353
|
+
FOREIGN KEY (customer_id) REFERENCES customers(id)
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
CREATE TABLE IF NOT EXISTS domain_events (
|
|
357
|
+
id TEXT PRIMARY KEY,
|
|
358
|
+
event_name TEXT NOT NULL,
|
|
359
|
+
entity_type TEXT NOT NULL,
|
|
360
|
+
entity_id TEXT NOT NULL,
|
|
361
|
+
payload TEXT NOT NULL,
|
|
362
|
+
created_at TEXT NOT NULL
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
366
|
+
id TEXT PRIMARY KEY,
|
|
367
|
+
event_name TEXT NOT NULL,
|
|
368
|
+
actor_id TEXT,
|
|
369
|
+
entity_type TEXT,
|
|
370
|
+
entity_id TEXT,
|
|
371
|
+
payload TEXT NOT NULL,
|
|
372
|
+
created_at TEXT NOT NULL
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers(email);
|
|
376
|
+
CREATE INDEX IF NOT EXISTS idx_bookings_customer_id ON bookings(customer_id);
|
|
377
|
+
CREATE INDEX IF NOT EXISTS idx_bookings_starts_at ON bookings(starts_at);
|
|
378
|
+
CREATE INDEX IF NOT EXISTS idx_domain_events_name ON domain_events(event_name);
|
|
379
|
+
CREATE INDEX IF NOT EXISTS idx_audit_events_entity ON audit_events(entity_type, entity_id);
|
|
380
|
+
`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function buildTsConfig() {
|
|
384
|
+
return json({
|
|
385
|
+
compilerOptions: {
|
|
386
|
+
target: "ES2022",
|
|
387
|
+
module: "ESNext",
|
|
388
|
+
moduleResolution: "Bundler",
|
|
389
|
+
lib: ["ES2022"],
|
|
390
|
+
types: ["@cloudflare/workers-types"],
|
|
391
|
+
strict: true,
|
|
392
|
+
skipLibCheck: true,
|
|
393
|
+
noEmit: true,
|
|
394
|
+
},
|
|
395
|
+
include: ["src/**/*.ts"],
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildEnvTs() {
|
|
400
|
+
return `export interface Env {
|
|
401
|
+
DB: D1Database;
|
|
402
|
+
CACHE_KV: KVNamespace;
|
|
403
|
+
NOTIFICATIONS?: Queue;
|
|
404
|
+
}
|
|
405
|
+
`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function buildAuditTs() {
|
|
409
|
+
return `import type { Env } from "./env";
|
|
410
|
+
|
|
411
|
+
export interface AuditPayload {
|
|
412
|
+
actorId?: string;
|
|
413
|
+
entityType?: string;
|
|
414
|
+
entityId?: string;
|
|
415
|
+
payload?: unknown;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function writeAudit(env: Env, eventName: string, input: AuditPayload = {}) {
|
|
419
|
+
const now = new Date().toISOString();
|
|
420
|
+
const id = crypto.randomUUID();
|
|
421
|
+
|
|
422
|
+
await env.DB.prepare(
|
|
423
|
+
"INSERT INTO audit_events (id, event_name, actor_id, entity_type, entity_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
424
|
+
)
|
|
425
|
+
.bind(
|
|
426
|
+
id,
|
|
427
|
+
eventName,
|
|
428
|
+
input.actorId ?? null,
|
|
429
|
+
input.entityType ?? null,
|
|
430
|
+
input.entityId ?? null,
|
|
431
|
+
JSON.stringify(input.payload ?? {}),
|
|
432
|
+
now
|
|
433
|
+
)
|
|
434
|
+
.run();
|
|
435
|
+
|
|
436
|
+
return { id, eventName, createdAt: now };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export async function emitDomainEvent(
|
|
440
|
+
env: Env,
|
|
441
|
+
eventName: string,
|
|
442
|
+
entityType: string,
|
|
443
|
+
entityId: string,
|
|
444
|
+
payload: unknown = {}
|
|
445
|
+
) {
|
|
446
|
+
const now = new Date().toISOString();
|
|
447
|
+
const id = crypto.randomUUID();
|
|
448
|
+
|
|
449
|
+
await env.DB.prepare(
|
|
450
|
+
"INSERT INTO domain_events (id, event_name, entity_type, entity_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
451
|
+
)
|
|
452
|
+
.bind(id, eventName, entityType, entityId, JSON.stringify(payload), now)
|
|
453
|
+
.run();
|
|
454
|
+
|
|
455
|
+
return { id, eventName, createdAt: now };
|
|
456
|
+
}
|
|
457
|
+
`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function buildHooksTs(composition) {
|
|
461
|
+
return `export interface HookResult<T = unknown> {
|
|
462
|
+
ok: boolean;
|
|
463
|
+
value?: T;
|
|
464
|
+
warnings?: string[];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export interface BookingDraft {
|
|
468
|
+
customerId: string;
|
|
469
|
+
serviceType: string;
|
|
470
|
+
startsAt: string;
|
|
471
|
+
endsAt: string;
|
|
472
|
+
metadata?: Record<string, unknown>;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export const hookConfig = ${JSON.stringify(composition.config, null, 2)} as const;
|
|
476
|
+
|
|
477
|
+
export async function beforeSignup(input: { email: string; role: string }): Promise<HookResult<typeof input>> {
|
|
478
|
+
return { ok: true, value: input };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export async function beforeCustomerCreate<T extends Record<string, unknown>>(input: T): Promise<HookResult<T>> {
|
|
482
|
+
return { ok: true, value: input };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function beforeBookingCreate(input: BookingDraft): Promise<HookResult<BookingDraft>> {
|
|
486
|
+
return { ok: true, value: input };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function calculateAvailability(
|
|
490
|
+
input: { serviceType: string; from: string; to: string }
|
|
491
|
+
): Promise<HookResult<{ serviceType: string; slots: unknown[]; generatedBy: string }>> {
|
|
492
|
+
return {
|
|
493
|
+
ok: true,
|
|
494
|
+
value: {
|
|
495
|
+
serviceType: input.serviceType,
|
|
496
|
+
slots: [],
|
|
497
|
+
generatedBy: "microservices.sh hook placeholder",
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export async function afterBookingConfirmed(input: { bookingId: string; customerId: string }) {
|
|
503
|
+
return { ok: true, value: input };
|
|
504
|
+
}
|
|
505
|
+
`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function buildIndexTs(composition) {
|
|
509
|
+
const routeImports = composition.modules
|
|
510
|
+
.map((module) => `import { ${module.id.replace(/-([a-z])/g, (_, char) => char.toUpperCase())}Routes } from "./modules/${module.id}";`)
|
|
511
|
+
.join("\n");
|
|
512
|
+
const routeMounts = composition.modules
|
|
513
|
+
.map((module) => `app.route("${module.runtime.mount}", ${module.id.replace(/-([a-z])/g, (_, char) => char.toUpperCase())}Routes);`)
|
|
514
|
+
.join("\n");
|
|
515
|
+
|
|
516
|
+
return `import { Hono } from "hono";
|
|
517
|
+
import type { Env } from "./lib/env";
|
|
518
|
+
${routeImports}
|
|
519
|
+
|
|
520
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
521
|
+
|
|
522
|
+
app.get("/health", (c) =>
|
|
523
|
+
c.json({
|
|
524
|
+
ok: true,
|
|
525
|
+
app: ${JSON.stringify(composition.config.appName ?? composition.template.name)},
|
|
526
|
+
template: ${JSON.stringify(composition.template.id)},
|
|
527
|
+
modules: ${JSON.stringify(composition.modules.map((module) => module.id))},
|
|
528
|
+
})
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
${routeMounts}
|
|
532
|
+
|
|
533
|
+
export default app;
|
|
534
|
+
`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function buildAuthModuleTs() {
|
|
538
|
+
return `import { Hono } from "hono";
|
|
539
|
+
import type { Env } from "../lib/env";
|
|
540
|
+
import { emitDomainEvent, writeAudit } from "../lib/audit";
|
|
541
|
+
import { beforeSignup } from "../lib/hooks";
|
|
542
|
+
|
|
543
|
+
export const authRoutes = new Hono<{ Bindings: Env }>();
|
|
544
|
+
|
|
545
|
+
authRoutes.post("/signup", async (c) => {
|
|
546
|
+
const body = (await c.req.json<{ email?: string; role?: string }>().catch(() => ({}))) as {
|
|
547
|
+
email?: string;
|
|
548
|
+
role?: string;
|
|
549
|
+
};
|
|
550
|
+
const email = String(body.email ?? "").trim().toLowerCase();
|
|
551
|
+
const role = String(body.role ?? "member").trim() || "member";
|
|
552
|
+
|
|
553
|
+
if (!email.includes("@")) {
|
|
554
|
+
return c.json({ ok: false, error: "A valid email is required." }, 400);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const hook = await beforeSignup({ email, role });
|
|
558
|
+
if (!hook.ok || !hook.value) {
|
|
559
|
+
return c.json({ ok: false, error: "Signup rejected by beforeSignup hook.", warnings: hook.warnings ?? [] }, 422);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const now = new Date().toISOString();
|
|
563
|
+
const id = crypto.randomUUID();
|
|
564
|
+
|
|
565
|
+
await c.env.DB.prepare("INSERT INTO users (id, email, role, created_at) VALUES (?, ?, ?, ?)")
|
|
566
|
+
.bind(id, hook.value.email, hook.value.role, now)
|
|
567
|
+
.run();
|
|
568
|
+
|
|
569
|
+
await emitDomainEvent(c.env, "auth.user_created", "user", id, { email: hook.value.email, role: hook.value.role });
|
|
570
|
+
await writeAudit(c.env, "auth.user_created", {
|
|
571
|
+
actorId: id,
|
|
572
|
+
entityType: "user",
|
|
573
|
+
entityId: id,
|
|
574
|
+
payload: { email: hook.value.email, role: hook.value.role },
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return c.json({ ok: true, user: { id, email: hook.value.email, role: hook.value.role } }, 201);
|
|
578
|
+
});
|
|
579
|
+
`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function buildCustomerModuleTs() {
|
|
583
|
+
return `import { Hono } from "hono";
|
|
584
|
+
import type { Env } from "../lib/env";
|
|
585
|
+
import { emitDomainEvent, writeAudit } from "../lib/audit";
|
|
586
|
+
import { beforeCustomerCreate } from "../lib/hooks";
|
|
587
|
+
|
|
588
|
+
export const customerRoutes = new Hono<{ Bindings: Env }>();
|
|
589
|
+
|
|
590
|
+
customerRoutes.get("/", async (c) => {
|
|
591
|
+
const rows = await c.env.DB.prepare(
|
|
592
|
+
"SELECT id, user_id, name, email, phone, notes, tags, created_at, updated_at FROM customers ORDER BY created_at DESC LIMIT 50"
|
|
593
|
+
).all();
|
|
594
|
+
return c.json({ ok: true, customers: rows.results ?? [] });
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
customerRoutes.post("/", async (c) => {
|
|
598
|
+
const body = (await c.req.json<Record<string, unknown>>().catch(() => ({}))) as Record<string, unknown>;
|
|
599
|
+
const draft = await beforeCustomerCreate({
|
|
600
|
+
userId: typeof body.userId === "string" ? body.userId : null,
|
|
601
|
+
name: String(body.name ?? "").trim(),
|
|
602
|
+
email: String(body.email ?? "").trim().toLowerCase(),
|
|
603
|
+
phone: typeof body.phone === "string" ? body.phone : null,
|
|
604
|
+
notes: typeof body.notes === "string" ? body.notes : null,
|
|
605
|
+
tags: Array.isArray(body.tags) ? body.tags : [],
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
if (!draft.ok || !draft.value?.name || !String(draft.value.email).includes("@")) {
|
|
609
|
+
return c.json({ ok: false, error: "Customer name and valid email are required.", warnings: draft.warnings ?? [] }, 400);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const now = new Date().toISOString();
|
|
613
|
+
const id = crypto.randomUUID();
|
|
614
|
+
|
|
615
|
+
await c.env.DB.prepare(
|
|
616
|
+
"INSERT INTO customers (id, user_id, name, email, phone, notes, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
617
|
+
)
|
|
618
|
+
.bind(
|
|
619
|
+
id,
|
|
620
|
+
draft.value.userId,
|
|
621
|
+
draft.value.name,
|
|
622
|
+
draft.value.email,
|
|
623
|
+
draft.value.phone,
|
|
624
|
+
draft.value.notes,
|
|
625
|
+
JSON.stringify(draft.value.tags),
|
|
626
|
+
now,
|
|
627
|
+
now
|
|
628
|
+
)
|
|
629
|
+
.run();
|
|
630
|
+
|
|
631
|
+
await emitDomainEvent(c.env, "customer.created", "customer", id, draft.value);
|
|
632
|
+
await writeAudit(c.env, "customer.created", {
|
|
633
|
+
entityType: "customer",
|
|
634
|
+
entityId: id,
|
|
635
|
+
payload: draft.value,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
return c.json({ ok: true, customer: { id, ...draft.value } }, 201);
|
|
639
|
+
});
|
|
640
|
+
`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function buildBookingModuleTs() {
|
|
644
|
+
return `import { Hono } from "hono";
|
|
645
|
+
import type { Env } from "../lib/env";
|
|
646
|
+
import { emitDomainEvent, writeAudit } from "../lib/audit";
|
|
647
|
+
import { afterBookingConfirmed, beforeBookingCreate, calculateAvailability } from "../lib/hooks";
|
|
648
|
+
|
|
649
|
+
export const bookingRoutes = new Hono<{ Bindings: Env }>();
|
|
650
|
+
|
|
651
|
+
bookingRoutes.get("/availability", async (c) => {
|
|
652
|
+
const serviceType = c.req.query("serviceType") ?? "standard-service";
|
|
653
|
+
const from = c.req.query("from") ?? new Date().toISOString();
|
|
654
|
+
const to = c.req.query("to") ?? from;
|
|
655
|
+
const availability = await calculateAvailability({ serviceType, from, to });
|
|
656
|
+
return c.json({ ok: true, availability: availability.value, warnings: availability.warnings ?? [] });
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
bookingRoutes.post("/", async (c) => {
|
|
660
|
+
const body = (await c.req.json<Record<string, unknown>>().catch(() => ({}))) as Record<string, unknown>;
|
|
661
|
+
const draft = await beforeBookingCreate({
|
|
662
|
+
customerId: String(body.customerId ?? ""),
|
|
663
|
+
serviceType: String(body.serviceType ?? "standard-service"),
|
|
664
|
+
startsAt: String(body.startsAt ?? ""),
|
|
665
|
+
endsAt: String(body.endsAt ?? ""),
|
|
666
|
+
metadata: typeof body.metadata === "object" && body.metadata !== null ? body.metadata as Record<string, unknown> : {},
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
if (!draft.ok || !draft.value?.customerId || !draft.value.startsAt || !draft.value.endsAt) {
|
|
670
|
+
return c.json({ ok: false, error: "customerId, startsAt, and endsAt are required.", warnings: draft.warnings ?? [] }, 400);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const now = new Date().toISOString();
|
|
674
|
+
const id = crypto.randomUUID();
|
|
675
|
+
|
|
676
|
+
await c.env.DB.prepare(
|
|
677
|
+
"INSERT INTO bookings (id, customer_id, service_type, status, starts_at, ends_at, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
678
|
+
)
|
|
679
|
+
.bind(
|
|
680
|
+
id,
|
|
681
|
+
draft.value.customerId,
|
|
682
|
+
draft.value.serviceType,
|
|
683
|
+
"confirmed",
|
|
684
|
+
draft.value.startsAt,
|
|
685
|
+
draft.value.endsAt,
|
|
686
|
+
JSON.stringify(draft.value.metadata ?? {}),
|
|
687
|
+
now,
|
|
688
|
+
now
|
|
689
|
+
)
|
|
690
|
+
.run();
|
|
691
|
+
|
|
692
|
+
await afterBookingConfirmed({ bookingId: id, customerId: draft.value.customerId });
|
|
693
|
+
await emitDomainEvent(c.env, "booking.confirmed", "booking", id, draft.value);
|
|
694
|
+
await writeAudit(c.env, "booking.confirmed", {
|
|
695
|
+
entityType: "booking",
|
|
696
|
+
entityId: id,
|
|
697
|
+
payload: draft.value,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
return c.json({ ok: true, booking: { id, status: "confirmed", ...draft.value } }, 201);
|
|
701
|
+
});
|
|
702
|
+
`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function buildAgentReadme(composition) {
|
|
706
|
+
const moduleList = composition.modules.map((module) => `- ${module.name} (${module.id}) at ${module.runtime.mount}`).join("\n");
|
|
707
|
+
const hookList = composition.hooks.map((hook) => `- ${hook.name}: ${hook.purpose}`).join("\n");
|
|
708
|
+
|
|
709
|
+
return `# ${composition.config.appName ?? composition.template.name}
|
|
710
|
+
|
|
711
|
+
Generated by microservices.sh local MVP tooling.
|
|
712
|
+
|
|
713
|
+
## Modules
|
|
714
|
+
${moduleList}
|
|
715
|
+
|
|
716
|
+
## Safe Customization
|
|
717
|
+
Use configuration and hooks before editing module route internals.
|
|
718
|
+
|
|
719
|
+
Hooks live in \`src/lib/hooks.ts\`:
|
|
720
|
+
${hookList}
|
|
721
|
+
|
|
722
|
+
## Audit And Events
|
|
723
|
+
Domain events are written to \`domain_events\`.
|
|
724
|
+
Audit events are written to \`audit_events\`.
|
|
725
|
+
Every generated mutation should emit both a domain event and an audit event.
|
|
726
|
+
|
|
727
|
+
## Local Commands
|
|
728
|
+
\`\`\`bash
|
|
729
|
+
pnpm install
|
|
730
|
+
pnpm microservices modules list --json
|
|
731
|
+
pnpm microservices docs booking
|
|
732
|
+
pnpm microservices upgrade booking --plan --json
|
|
733
|
+
pnpm microservices check --json
|
|
734
|
+
pnpm db:init
|
|
735
|
+
pnpm dev
|
|
736
|
+
\`\`\`
|
|
737
|
+
|
|
738
|
+
Use \`pnpm microservices add <module-id> --plan --json\` before installing planned provider modules.
|
|
739
|
+
Use \`pnpm microservices upgrade <module-id> --plan --json\` before updating a locked module.
|
|
740
|
+
|
|
741
|
+
## Deploy
|
|
742
|
+
Create D1/KV resources, replace IDs in \`wrangler.jsonc\`, then run:
|
|
743
|
+
|
|
744
|
+
\`\`\`bash
|
|
745
|
+
pnpm deploy
|
|
746
|
+
\`\`\`
|
|
747
|
+
`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function buildProjectCliJs() {
|
|
751
|
+
const script = `#!/usr/bin/env node
|
|
752
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
753
|
+
import { spawnSync } from "node:child_process";
|
|
754
|
+
|
|
755
|
+
function parseArgs(argv) {
|
|
756
|
+
const args = [];
|
|
757
|
+
const flags = { json: false, plan: false };
|
|
758
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
759
|
+
const value = argv[index];
|
|
760
|
+
if (value === "--json") flags.json = true;
|
|
761
|
+
else if (value === "--plan") flags.plan = true;
|
|
762
|
+
else args.push(value);
|
|
763
|
+
}
|
|
764
|
+
return { args, flags };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function readJson(path, fallback = null) {
|
|
768
|
+
if (!existsSync(path)) return fallback;
|
|
769
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function catalog() {
|
|
773
|
+
return readJson("docs/modules/catalog.json", { modules: [] });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function lockfile() {
|
|
777
|
+
return readJson("microservices.lock.json", { modules: [] });
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function ok(data, warnings = []) {
|
|
781
|
+
return { ok: true, data, warnings };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function fail(message, remediation, details = {}) {
|
|
785
|
+
return { ok: false, error: { message, remediation, details } };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function writeJson(value) {
|
|
789
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\\n");
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function findModule(id) {
|
|
793
|
+
return catalog().modules.find((module) => module.id === id);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function modulePlan(id) {
|
|
797
|
+
const module = findModule(id);
|
|
798
|
+
if (!module) {
|
|
799
|
+
return fail("Unknown module.", "Run pnpm microservices modules list --json and pick a returned id.", { id });
|
|
800
|
+
}
|
|
801
|
+
const lock = lockfile();
|
|
802
|
+
const installed = new Set((lock.modules ?? []).map((item) => item.id));
|
|
803
|
+
const missingDependencies = (module.requires ?? []).filter((item) => !installed.has(item));
|
|
804
|
+
const alreadyInstalled = installed.has(module.id);
|
|
805
|
+
const gated = module.approvalRisk === "high" || (module.secrets ?? []).length > 0 || module.status !== "available";
|
|
806
|
+
|
|
807
|
+
return ok({
|
|
808
|
+
module,
|
|
809
|
+
action: alreadyInstalled ? "already-installed" : module.status === "available" ? "install" : "planned-install",
|
|
810
|
+
alreadyInstalled,
|
|
811
|
+
missingDependencies,
|
|
812
|
+
approvalRequired: gated,
|
|
813
|
+
requiredSecrets: module.secrets ?? [],
|
|
814
|
+
requiredResources: module.resources ?? [],
|
|
815
|
+
requiredPermissions: module.permissions ?? [],
|
|
816
|
+
filesLikelyTouched: [
|
|
817
|
+
"microservices.lock.json",
|
|
818
|
+
"wrangler.jsonc",
|
|
819
|
+
"schema.sql",
|
|
820
|
+
"src/index.ts",
|
|
821
|
+
"src/modules/" + module.id + ".ts",
|
|
822
|
+
"docs/modules/" + module.id + ".md",
|
|
823
|
+
],
|
|
824
|
+
nextSteps: [
|
|
825
|
+
"Review this plan with the user.",
|
|
826
|
+
"Confirm gated side effects before changing secrets, resources, webhooks, migrations, or deploy settings.",
|
|
827
|
+
"Apply generated code changes only after approval.",
|
|
828
|
+
"Run pnpm microservices check --json and pnpm typecheck after changes.",
|
|
829
|
+
],
|
|
830
|
+
}, module.status === "planned" ? ["Module is planned; this command produces a plan only."] : []);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function asStringArray(value) {
|
|
834
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function stringDiff(fromValues, toValues, hasSnapshot) {
|
|
838
|
+
const from = hasSnapshot ? new Set(asStringArray(fromValues)) : new Set();
|
|
839
|
+
const to = new Set(asStringArray(toValues));
|
|
840
|
+
return {
|
|
841
|
+
added: hasSnapshot ? Array.from(to).filter((item) => !from.has(item)) : [],
|
|
842
|
+
removed: hasSnapshot ? Array.from(from).filter((item) => !to.has(item)) : [],
|
|
843
|
+
unchanged: Array.from(to).filter((item) => from.has(item)),
|
|
844
|
+
snapshotAvailable: hasSnapshot,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function customizationMatches(value, moduleId) {
|
|
849
|
+
if (typeof value === "string") return value === moduleId || value.startsWith(moduleId + ":") || value.startsWith(moduleId + "/");
|
|
850
|
+
if (value && typeof value === "object") {
|
|
851
|
+
return value.module === moduleId || value.moduleId === moduleId || value.id === moduleId;
|
|
852
|
+
}
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function matchingCustomizations(items, moduleId) {
|
|
857
|
+
return Array.isArray(items) ? items.filter((item) => customizationMatches(item, moduleId)) : [];
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function targetContract(module) {
|
|
861
|
+
return {
|
|
862
|
+
mount: module.mount,
|
|
863
|
+
resources: module.resources ?? [],
|
|
864
|
+
permissions: module.permissions ?? [],
|
|
865
|
+
hooks: module.hooks ?? [],
|
|
866
|
+
events: module.events ?? [],
|
|
867
|
+
requires: module.requires ?? [],
|
|
868
|
+
secrets: module.secrets ?? [],
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function filesForUpgrade(module, diff) {
|
|
873
|
+
const files = new Set(["microservices.lock.json", "docs/modules/" + module.id + ".md"]);
|
|
874
|
+
if (module.status === "available") files.add("src/modules/" + module.id + ".ts");
|
|
875
|
+
if (diff.resources.added.length || diff.resources.removed.length || (module.secrets ?? []).length) files.add("wrangler.jsonc");
|
|
876
|
+
if ((module.resources ?? []).some((resource) => String(resource).toLowerCase().includes("d1"))) files.add("schema.sql");
|
|
877
|
+
if (diff.hooks.added.length || diff.hooks.removed.length || (module.hooks ?? []).length) files.add("src/lib/hooks.ts");
|
|
878
|
+
return Array.from(files);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function upgradePlan(id) {
|
|
882
|
+
const module = findModule(id);
|
|
883
|
+
if (!module) {
|
|
884
|
+
return fail("Unknown module.", "Run pnpm microservices modules list --json and pick a returned id.", { id });
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const lock = lockfile();
|
|
888
|
+
const locked = (lock.modules ?? []).find((item) => item.id === id);
|
|
889
|
+
if (!locked) {
|
|
890
|
+
return fail("Module is not installed.", "Run pnpm microservices modules list --json, then plan an upgrade for an installed module.", { id });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const hasSnapshot = Boolean(locked.contract && typeof locked.contract === "object");
|
|
894
|
+
const currentContract = locked.contract ?? {};
|
|
895
|
+
const nextContract = targetContract(module);
|
|
896
|
+
const upgradeAvailable = locked.version !== module.version;
|
|
897
|
+
const diff = {
|
|
898
|
+
mount: hasSnapshot && currentContract.mount !== nextContract.mount
|
|
899
|
+
? { from: currentContract.mount ?? null, to: nextContract.mount }
|
|
900
|
+
: { from: currentContract.mount ?? nextContract.mount, to: nextContract.mount },
|
|
901
|
+
resources: stringDiff(currentContract.resources, nextContract.resources, hasSnapshot),
|
|
902
|
+
permissions: stringDiff(currentContract.permissions, nextContract.permissions, hasSnapshot),
|
|
903
|
+
hooks: stringDiff(currentContract.hooks, nextContract.hooks, hasSnapshot),
|
|
904
|
+
events: stringDiff(currentContract.events, nextContract.events, hasSnapshot),
|
|
905
|
+
requires: stringDiff(currentContract.requires, nextContract.requires, hasSnapshot),
|
|
906
|
+
secrets: {
|
|
907
|
+
added: upgradeAvailable ? nextContract.secrets : [],
|
|
908
|
+
removed: [],
|
|
909
|
+
unchanged: upgradeAvailable ? [] : nextContract.secrets,
|
|
910
|
+
snapshotAvailable: false,
|
|
911
|
+
},
|
|
912
|
+
};
|
|
913
|
+
const customizations = lock.customizations ?? {};
|
|
914
|
+
const customizationImpact = {
|
|
915
|
+
configPreserved: customizations.config !== false,
|
|
916
|
+
hooksToReview: module.hooks ?? [],
|
|
917
|
+
overlaysToReview: matchingCustomizations(customizations.overlays, module.id),
|
|
918
|
+
forksToReview: matchingCustomizations(customizations.forks, module.id),
|
|
919
|
+
};
|
|
920
|
+
const hasForks = customizationImpact.forksToReview.length > 0;
|
|
921
|
+
const hasOverlays = customizationImpact.overlaysToReview.length > 0;
|
|
922
|
+
const changesResources = diff.resources.added.length > 0 || diff.resources.removed.length > 0;
|
|
923
|
+
const changesPermissions = diff.permissions.added.length > 0 || diff.permissions.removed.length > 0;
|
|
924
|
+
const changesSecrets = diff.secrets.added.length > 0 || diff.secrets.removed.length > 0;
|
|
925
|
+
const approvalRequired = upgradeAvailable && (
|
|
926
|
+
module.approvalRisk === "high" || changesSecrets || changesResources || changesPermissions || hasForks || hasOverlays
|
|
927
|
+
);
|
|
928
|
+
const risk = !upgradeAvailable
|
|
929
|
+
? "low"
|
|
930
|
+
: approvalRequired || hasForks
|
|
931
|
+
? "high"
|
|
932
|
+
: hasOverlays || diff.hooks.added.length || diff.hooks.removed.length
|
|
933
|
+
? "medium"
|
|
934
|
+
: "low";
|
|
935
|
+
|
|
936
|
+
return ok({
|
|
937
|
+
module: {
|
|
938
|
+
id: module.id,
|
|
939
|
+
name: module.name,
|
|
940
|
+
status: module.status,
|
|
941
|
+
currentVersion: locked.version,
|
|
942
|
+
targetVersion: module.version,
|
|
943
|
+
},
|
|
944
|
+
action: upgradeAvailable ? "upgrade-plan" : "no-op",
|
|
945
|
+
upgradeAvailable,
|
|
946
|
+
approvalRequired,
|
|
947
|
+
risk,
|
|
948
|
+
lockfile: {
|
|
949
|
+
schemaVersion: lock.schemaVersion ?? null,
|
|
950
|
+
registryVersion: catalog().schemaVersion ?? null,
|
|
951
|
+
template: lock.template ?? null,
|
|
952
|
+
source: locked.source ?? null,
|
|
953
|
+
checksum: locked.checksum ?? null,
|
|
954
|
+
contractSnapshotAvailable: hasSnapshot,
|
|
955
|
+
},
|
|
956
|
+
diff,
|
|
957
|
+
customizationImpact,
|
|
958
|
+
filesLikelyTouched: upgradeAvailable ? filesForUpgrade(module, diff) : [],
|
|
959
|
+
permissionGate: {
|
|
960
|
+
required: approvalRequired,
|
|
961
|
+
reasons: [
|
|
962
|
+
module.approvalRisk === "high" ? "high-risk module" : null,
|
|
963
|
+
changesSecrets ? "secret changes" : null,
|
|
964
|
+
changesResources ? "resource changes" : null,
|
|
965
|
+
changesPermissions ? "permission changes" : null,
|
|
966
|
+
hasOverlays ? "overlay customizations require merge review" : null,
|
|
967
|
+
hasForks ? "forked module requires manual merge review" : null,
|
|
968
|
+
].filter(Boolean),
|
|
969
|
+
},
|
|
970
|
+
nextSteps: upgradeAvailable
|
|
971
|
+
? [
|
|
972
|
+
"Review this plan with the user before modifying source.",
|
|
973
|
+
"Create a branch or patch for the upgrade.",
|
|
974
|
+
"Review hook, overlay, and fork impacts before applying generated changes.",
|
|
975
|
+
"Run pnpm microservices check --json and pnpm typecheck after applying.",
|
|
976
|
+
"Deploy preview only after approval for resources, migrations, webhooks, or secrets.",
|
|
977
|
+
]
|
|
978
|
+
: [
|
|
979
|
+
"No upgrade is available from the current registry snapshot.",
|
|
980
|
+
"Run microservices updates --json later to check again.",
|
|
981
|
+
],
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function updates() {
|
|
986
|
+
const lock = lockfile();
|
|
987
|
+
const modules = catalog().modules;
|
|
988
|
+
const byId = new Map(modules.map((module) => [module.id, module]));
|
|
989
|
+
const current = [];
|
|
990
|
+
const unavailable = [];
|
|
991
|
+
|
|
992
|
+
for (const locked of lock.modules ?? []) {
|
|
993
|
+
const registryModule = byId.get(locked.id);
|
|
994
|
+
if (!registryModule) {
|
|
995
|
+
unavailable.push({ id: locked.id, currentVersion: locked.version, reason: "No matching catalog module." });
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
current.push({
|
|
999
|
+
id: locked.id,
|
|
1000
|
+
currentVersion: locked.version,
|
|
1001
|
+
latestVersion: registryModule.version,
|
|
1002
|
+
status: locked.version === registryModule.version ? "current" : "update-available",
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return ok({
|
|
1007
|
+
schemaVersion: lock.schemaVersion ?? null,
|
|
1008
|
+
registryVersion: catalog().schemaVersion ?? null,
|
|
1009
|
+
template: lock.template ?? null,
|
|
1010
|
+
current,
|
|
1011
|
+
available: current.filter((item) => item.status === "update-available"),
|
|
1012
|
+
unavailable,
|
|
1013
|
+
policy: lock.customizations ?? {
|
|
1014
|
+
config: true,
|
|
1015
|
+
hooks: [],
|
|
1016
|
+
overlays: [],
|
|
1017
|
+
forks: [],
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function secretsStatus() {
|
|
1023
|
+
const installedIds = new Set((lockfile().modules ?? []).map((item) => item.id));
|
|
1024
|
+
const modules = catalog().modules.filter((module) => installedIds.has(module.id) || (module.secrets ?? []).length > 0);
|
|
1025
|
+
return ok({
|
|
1026
|
+
secrets: modules.flatMap((module) =>
|
|
1027
|
+
(module.secrets ?? []).map((name) => ({
|
|
1028
|
+
module: module.id,
|
|
1029
|
+
name,
|
|
1030
|
+
configured: false,
|
|
1031
|
+
scope: "project/env/module/" + module.id + "/" + name,
|
|
1032
|
+
valueVisibleToAgent: false,
|
|
1033
|
+
}))
|
|
1034
|
+
),
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function checks() {
|
|
1039
|
+
const requiredFiles = [
|
|
1040
|
+
"package.json",
|
|
1041
|
+
"wrangler.jsonc",
|
|
1042
|
+
"schema.sql",
|
|
1043
|
+
"microservices.lock.json",
|
|
1044
|
+
"docs/llms.txt",
|
|
1045
|
+
"docs/modules/catalog.json",
|
|
1046
|
+
"src/index.ts",
|
|
1047
|
+
"src/lib/hooks.ts",
|
|
1048
|
+
];
|
|
1049
|
+
const result = requiredFiles.map((path) => ({
|
|
1050
|
+
id: "file:" + path,
|
|
1051
|
+
status: existsSync(path) ? "pass" : "fail",
|
|
1052
|
+
message: existsSync(path) ? path + " exists." : path + " is missing.",
|
|
1053
|
+
}));
|
|
1054
|
+
return ok({
|
|
1055
|
+
status: result.every((item) => item.status === "pass") ? "pass" : "fail",
|
|
1056
|
+
checks: result,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function usage() {
|
|
1061
|
+
return "microservices project CLI\\n\\n" +
|
|
1062
|
+
"Usage:\\n" +
|
|
1063
|
+
" pnpm microservices modules list [--json]\\n" +
|
|
1064
|
+
" pnpm microservices modules inspect <id> [--json]\\n" +
|
|
1065
|
+
" pnpm microservices docs <id> [--json]\\n" +
|
|
1066
|
+
" pnpm microservices add <id> --plan [--json]\\n" +
|
|
1067
|
+
" pnpm microservices secrets status [--json]\\n" +
|
|
1068
|
+
" pnpm microservices updates [--json]\\n" +
|
|
1069
|
+
" pnpm microservices upgrade <id> --plan [--json]\\n" +
|
|
1070
|
+
" pnpm microservices check [--json]\\n" +
|
|
1071
|
+
" pnpm microservices dev\\n";
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const { args, flags } = parseArgs(process.argv.slice(2));
|
|
1075
|
+
const [resource, action, value] = args;
|
|
1076
|
+
let response;
|
|
1077
|
+
|
|
1078
|
+
if (!resource || resource === "help" || resource === "--help" || resource === "-h") {
|
|
1079
|
+
process.stdout.write(usage());
|
|
1080
|
+
} else if (resource === "modules" && action === "list") {
|
|
1081
|
+
response = ok(catalog().modules);
|
|
1082
|
+
flags.json ? writeJson(response) : process.stdout.write(response.data.map((module) => module.id + " (" + module.status + ") - " + module.summary).join("\\n") + "\\n");
|
|
1083
|
+
} else if (resource === "modules" && action === "inspect") {
|
|
1084
|
+
const module = findModule(value);
|
|
1085
|
+
response = module ? ok(module) : fail("Unknown module.", "Run modules list --json and pick a returned id.", { id: value });
|
|
1086
|
+
flags.json ? writeJson(response) : process.stdout.write(response.ok ? module.name + "\\n" + module.summary + "\\nMount: " + module.mount + "\\nStatus: " + module.status + "\\n" : "Error: " + response.error.message + "\\n");
|
|
1087
|
+
} else if (resource === "docs") {
|
|
1088
|
+
const module = findModule(action);
|
|
1089
|
+
if (!module) {
|
|
1090
|
+
response = fail("Unknown module.", "Run modules list --json and pick a returned id.", { id: action });
|
|
1091
|
+
} else if (!existsSync(module.docPath)) {
|
|
1092
|
+
response = fail("Module doc is missing.", "Regenerate the app or check docs/modules/catalog.json.", { path: module.docPath });
|
|
1093
|
+
} else {
|
|
1094
|
+
response = ok({ id: module.id, path: module.docPath, markdown: readFileSync(module.docPath, "utf8") });
|
|
1095
|
+
}
|
|
1096
|
+
flags.json ? writeJson(response) : process.stdout.write(response.ok ? response.data.markdown : "Error: " + response.error.message + "\\n");
|
|
1097
|
+
} else if (resource === "add") {
|
|
1098
|
+
if (!flags.plan) {
|
|
1099
|
+
response = fail("Add requires --plan in the MVP scaffold.", "Run pnpm microservices add <module-id> --plan --json.", { id: action });
|
|
1100
|
+
} else {
|
|
1101
|
+
response = modulePlan(action);
|
|
1102
|
+
}
|
|
1103
|
+
flags.json ? writeJson(response) : process.stdout.write(response.ok ? "Plan for " + response.data.module.id + ": " + response.data.action + "\\nApproval required: " + response.data.approvalRequired + "\\n" : "Error: " + response.error.message + "\\n");
|
|
1104
|
+
} else if (resource === "secrets" && action === "status") {
|
|
1105
|
+
response = secretsStatus();
|
|
1106
|
+
flags.json ? writeJson(response) : process.stdout.write((response.data.secrets.map((item) => item.module + ":" + item.name + " configured=" + item.configured).join("\\n") || "No required secrets for installed modules.") + "\\n");
|
|
1107
|
+
} else if (resource === "updates") {
|
|
1108
|
+
response = updates();
|
|
1109
|
+
flags.json ? writeJson(response) : process.stdout.write((response.data.current.map((item) => item.id + ": " + item.currentVersion + " -> " + item.latestVersion + " (" + item.status + ")").join("\\n") || "No locked modules.") + "\\n");
|
|
1110
|
+
} else if (resource === "upgrade") {
|
|
1111
|
+
if (!flags.plan) {
|
|
1112
|
+
response = fail("Upgrade requires --plan in the MVP scaffold.", "Run pnpm microservices upgrade <module-id> --plan --json.", { id: action });
|
|
1113
|
+
} else {
|
|
1114
|
+
response = upgradePlan(action);
|
|
1115
|
+
}
|
|
1116
|
+
flags.json ? writeJson(response) : process.stdout.write(response.ok ? "Upgrade plan for " + response.data.module.id + ": " + response.data.action + "\\nApproval required: " + response.data.approvalRequired + "\\nRisk: " + response.data.risk + "\\n" : "Error: " + response.error.message + "\\n");
|
|
1117
|
+
} else if (resource === "check") {
|
|
1118
|
+
response = checks();
|
|
1119
|
+
flags.json ? writeJson(response) : process.stdout.write(response.data.status + "\\n" + response.data.checks.map((item) => "- " + item.id + ": " + item.status).join("\\n") + "\\n");
|
|
1120
|
+
} else if (resource === "dev") {
|
|
1121
|
+
const child = spawnSync("pnpm", ["dev"], { stdio: "inherit" });
|
|
1122
|
+
process.exitCode = child.status ?? 1;
|
|
1123
|
+
} else {
|
|
1124
|
+
process.stderr.write(usage());
|
|
1125
|
+
process.exitCode = 1;
|
|
1126
|
+
}
|
|
1127
|
+
`;
|
|
1128
|
+
return script + "\n";
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function buildProjectFiles(composition) {
|
|
1132
|
+
const catalog = moduleCatalog();
|
|
1133
|
+
return [
|
|
1134
|
+
{ path: "package.json", contents: buildPackageJson(composition) },
|
|
1135
|
+
{ path: "tsconfig.json", contents: buildTsConfig() },
|
|
1136
|
+
{ path: "wrangler.jsonc", contents: buildWranglerJson(composition) },
|
|
1137
|
+
{ path: "schema.sql", contents: buildSchemaSql() },
|
|
1138
|
+
{ path: "microservices.lock.json", contents: json(composition.lock) },
|
|
1139
|
+
{ path: "microservices.config.json", contents: buildMicroservicesConfig(composition) },
|
|
1140
|
+
{ path: "README.agent.md", contents: buildAgentReadme(composition) },
|
|
1141
|
+
{ path: "docs/llms.txt", contents: buildLlmGuide(composition) },
|
|
1142
|
+
{ path: "docs/modules/catalog.json", contents: json(catalog) },
|
|
1143
|
+
...catalog.modules.map((module) => ({ path: docPathFor(module.id), contents: moduleDocMarkdown(module) })),
|
|
1144
|
+
{ path: "scripts/microservices.js", contents: buildProjectCliJs() },
|
|
1145
|
+
{ path: "src/index.ts", contents: buildIndexTs(composition) },
|
|
1146
|
+
{ path: "src/lib/env.ts", contents: buildEnvTs() },
|
|
1147
|
+
{ path: "src/lib/audit.ts", contents: buildAuditTs() },
|
|
1148
|
+
{ path: "src/lib/hooks.ts", contents: buildHooksTs(composition) },
|
|
1149
|
+
{ path: "src/modules/auth.ts", contents: buildAuthModuleTs() },
|
|
1150
|
+
{ path: "src/modules/customer.ts", contents: buildCustomerModuleTs() },
|
|
1151
|
+
{ path: "src/modules/booking.ts", contents: buildBookingModuleTs() },
|
|
1152
|
+
];
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
export function listTemplates() {
|
|
1156
|
+
return capture(() => listContractTemplates());
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
export function inspectTemplate(id) {
|
|
1160
|
+
return capture(() => inspectContractTemplate(id));
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
export function listModules() {
|
|
1164
|
+
return capture(() => listContractModules());
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
export function inspectModule(id) {
|
|
1168
|
+
return capture(() => inspectContractModule(id));
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export function listModuleDocs() {
|
|
1172
|
+
return capture(() =>
|
|
1173
|
+
moduleCatalog().modules.map((module) => ({
|
|
1174
|
+
id: module.id,
|
|
1175
|
+
name: module.name,
|
|
1176
|
+
status: module.status,
|
|
1177
|
+
docPath: module.docPath,
|
|
1178
|
+
summary: module.summary,
|
|
1179
|
+
approvalRisk: module.approvalRisk,
|
|
1180
|
+
}))
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
export function getModuleDoc(id) {
|
|
1185
|
+
return capture(() => {
|
|
1186
|
+
const module = findCatalogModule(id);
|
|
1187
|
+
return {
|
|
1188
|
+
id: module.id,
|
|
1189
|
+
path: module.docPath,
|
|
1190
|
+
markdown: moduleDocMarkdown(module),
|
|
1191
|
+
module,
|
|
1192
|
+
};
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export function composeApp(input = {}) {
|
|
1197
|
+
return capture(() => composeContractApp(input));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function lockedModuleIds(input = {}) {
|
|
1201
|
+
const lock = input.lock && typeof input.lock === "object" ? input.lock : null;
|
|
1202
|
+
if (Array.isArray(input.installedModules)) return input.installedModules;
|
|
1203
|
+
if (lock && Array.isArray(lock.modules)) return lock.modules.map((module) => module.id);
|
|
1204
|
+
if (input.templateId || input.template || input.modules || input.config) {
|
|
1205
|
+
return composeContractApp(input).modules.map((module) => module.id);
|
|
1206
|
+
}
|
|
1207
|
+
return composeContractApp({ templateId: "booking-business" }).modules.map((module) => module.id);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function moduleLock(input = {}) {
|
|
1211
|
+
if (input.lock && typeof input.lock === "object") return input.lock;
|
|
1212
|
+
return composeContractApp(input).lock;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function asStringArray(value) {
|
|
1216
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function stringDiff(fromValues, toValues, hasSnapshot = true) {
|
|
1220
|
+
const from = hasSnapshot ? new Set(asStringArray(fromValues)) : new Set();
|
|
1221
|
+
const to = new Set(asStringArray(toValues));
|
|
1222
|
+
return {
|
|
1223
|
+
added: hasSnapshot ? [...to].filter((item) => !from.has(item)) : [],
|
|
1224
|
+
removed: hasSnapshot ? [...from].filter((item) => !to.has(item)) : [],
|
|
1225
|
+
unchanged: [...to].filter((item) => from.has(item)),
|
|
1226
|
+
snapshotAvailable: hasSnapshot,
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function customizationMatches(value, moduleId) {
|
|
1231
|
+
if (typeof value === "string") return value === moduleId || value.startsWith(`${moduleId}:`) || value.startsWith(`${moduleId}/`);
|
|
1232
|
+
if (value && typeof value === "object") {
|
|
1233
|
+
return value.module === moduleId || value.moduleId === moduleId || value.id === moduleId;
|
|
1234
|
+
}
|
|
1235
|
+
return false;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function matchingCustomizations(items, moduleId) {
|
|
1239
|
+
return Array.isArray(items) ? items.filter((item) => customizationMatches(item, moduleId)) : [];
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function targetContract(module) {
|
|
1243
|
+
return {
|
|
1244
|
+
mount: module.mount,
|
|
1245
|
+
resources: module.resources,
|
|
1246
|
+
permissions: module.permissions,
|
|
1247
|
+
hooks: module.hooks,
|
|
1248
|
+
events: module.events,
|
|
1249
|
+
requires: module.requires,
|
|
1250
|
+
secrets: module.secrets,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function filesForUpgrade(module, diff) {
|
|
1255
|
+
const files = new Set(["microservices.lock.json", `docs/modules/${module.id}.md`]);
|
|
1256
|
+
|
|
1257
|
+
if (module.status === "available") {
|
|
1258
|
+
files.add(`src/modules/${module.id}.ts`);
|
|
1259
|
+
}
|
|
1260
|
+
if (diff.resources.added.length || diff.resources.removed.length || module.secrets.length) {
|
|
1261
|
+
files.add("wrangler.jsonc");
|
|
1262
|
+
}
|
|
1263
|
+
if (module.resources.some((resource) => resource.toLowerCase().includes("d1"))) {
|
|
1264
|
+
files.add("schema.sql");
|
|
1265
|
+
}
|
|
1266
|
+
if (diff.hooks.added.length || diff.hooks.removed.length || module.hooks.length) {
|
|
1267
|
+
files.add("src/lib/hooks.ts");
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
return [...files];
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
export function planAddModule(input = {}) {
|
|
1274
|
+
return capture(() => {
|
|
1275
|
+
const options = typeof input === "string" ? { moduleId: input } : input;
|
|
1276
|
+
const moduleId = options.moduleId ?? options.id;
|
|
1277
|
+
if (!moduleId) {
|
|
1278
|
+
const error = new Error("Missing module id.");
|
|
1279
|
+
error.code = "MODULE_ID_REQUIRED";
|
|
1280
|
+
error.remediation = "Pass a module id such as payment-stripe.";
|
|
1281
|
+
throw error;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const module = findCatalogModule(moduleId);
|
|
1285
|
+
const installed = new Set(lockedModuleIds(options));
|
|
1286
|
+
const alreadyInstalled = installed.has(module.id);
|
|
1287
|
+
const missingDependencies = module.requires.filter((id) => !installed.has(id));
|
|
1288
|
+
|
|
1289
|
+
return {
|
|
1290
|
+
module,
|
|
1291
|
+
action: alreadyInstalled ? "already-installed" : module.status === "available" ? "install" : "planned-install",
|
|
1292
|
+
alreadyInstalled,
|
|
1293
|
+
missingDependencies,
|
|
1294
|
+
approvalRequired: module.approvalRisk === "high" || module.secrets.length > 0 || module.status !== "available",
|
|
1295
|
+
requiredSecrets: module.secrets,
|
|
1296
|
+
requiredResources: module.resources,
|
|
1297
|
+
requiredPermissions: module.permissions,
|
|
1298
|
+
filesLikelyTouched: [
|
|
1299
|
+
"microservices.lock.json",
|
|
1300
|
+
"microservices.config.json",
|
|
1301
|
+
"wrangler.jsonc",
|
|
1302
|
+
"schema.sql",
|
|
1303
|
+
"src/index.ts",
|
|
1304
|
+
`src/modules/${module.id}.ts`,
|
|
1305
|
+
`docs/modules/${module.id}.md`,
|
|
1306
|
+
],
|
|
1307
|
+
nextSteps: [
|
|
1308
|
+
"Review the plan with the user.",
|
|
1309
|
+
"Request approval for gated side effects.",
|
|
1310
|
+
"Apply a branch or patch after approval.",
|
|
1311
|
+
"Run checks before preview deployment.",
|
|
1312
|
+
],
|
|
1313
|
+
};
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
export function getSecretsStatus(input = {}) {
|
|
1318
|
+
return capture(() => {
|
|
1319
|
+
const ids = new Set(lockedModuleIds(input));
|
|
1320
|
+
const modules = moduleCatalog().modules.filter((module) => ids.has(module.id) || module.secrets.length > 0);
|
|
1321
|
+
return {
|
|
1322
|
+
secrets: modules.flatMap((module) =>
|
|
1323
|
+
module.secrets.map((name) => ({
|
|
1324
|
+
module: module.id,
|
|
1325
|
+
name,
|
|
1326
|
+
configured: false,
|
|
1327
|
+
scope: `project/env/module/${module.id}/${name}`,
|
|
1328
|
+
valueVisibleToAgent: false,
|
|
1329
|
+
}))
|
|
1330
|
+
),
|
|
1331
|
+
};
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
export function checkUpdates(input = {}) {
|
|
1336
|
+
return capture(() => {
|
|
1337
|
+
const lock = moduleLock(input);
|
|
1338
|
+
const byId = new Map(moduleCatalog().modules.map((module) => [module.id, module]));
|
|
1339
|
+
const current = [];
|
|
1340
|
+
const unavailable = [];
|
|
1341
|
+
|
|
1342
|
+
for (const locked of lock.modules ?? []) {
|
|
1343
|
+
const module = byId.get(locked.id);
|
|
1344
|
+
if (!module) {
|
|
1345
|
+
unavailable.push({ id: locked.id, currentVersion: locked.version, reason: "No matching catalog module." });
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
current.push({
|
|
1349
|
+
id: locked.id,
|
|
1350
|
+
currentVersion: locked.version,
|
|
1351
|
+
latestVersion: module.version,
|
|
1352
|
+
status: locked.version === module.version ? "current" : "update-available",
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
return {
|
|
1357
|
+
schemaVersion: lock.schemaVersion ?? null,
|
|
1358
|
+
registryVersion: moduleCatalog().schemaVersion,
|
|
1359
|
+
template: lock.template ?? null,
|
|
1360
|
+
current,
|
|
1361
|
+
available: current.filter((item) => item.status === "update-available"),
|
|
1362
|
+
unavailable,
|
|
1363
|
+
policy: lock.customizations ?? {
|
|
1364
|
+
config: true,
|
|
1365
|
+
hooks: [],
|
|
1366
|
+
overlays: [],
|
|
1367
|
+
forks: [],
|
|
1368
|
+
},
|
|
1369
|
+
};
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
export function planModuleUpgrade(input = {}) {
|
|
1374
|
+
return capture(() => {
|
|
1375
|
+
const options = typeof input === "string" ? { moduleId: input } : input;
|
|
1376
|
+
const moduleId = options.moduleId ?? options.id;
|
|
1377
|
+
if (!moduleId) {
|
|
1378
|
+
const error = new Error("Missing module id.");
|
|
1379
|
+
error.code = "MODULE_ID_REQUIRED";
|
|
1380
|
+
error.remediation = "Pass a module id such as booking.";
|
|
1381
|
+
throw error;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const lock = moduleLock(options);
|
|
1385
|
+
const locked = (lock.modules ?? []).find((module) => module.id === moduleId);
|
|
1386
|
+
if (!locked) {
|
|
1387
|
+
const error = new Error(`Module is not installed: ${moduleId}`);
|
|
1388
|
+
error.code = "MODULE_NOT_INSTALLED";
|
|
1389
|
+
error.remediation = "Run microservices modules list --json, then plan an upgrade for an installed module.";
|
|
1390
|
+
error.details = { moduleId };
|
|
1391
|
+
throw error;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const module = findCatalogModule(moduleId);
|
|
1395
|
+
const hasSnapshot = Boolean(locked.contract && typeof locked.contract === "object");
|
|
1396
|
+
const currentContract = locked.contract ?? {};
|
|
1397
|
+
const nextContract = targetContract(module);
|
|
1398
|
+
const upgradeAvailable = locked.version !== module.version;
|
|
1399
|
+
const diff = {
|
|
1400
|
+
mount:
|
|
1401
|
+
hasSnapshot && currentContract.mount !== nextContract.mount
|
|
1402
|
+
? { from: currentContract.mount ?? null, to: nextContract.mount }
|
|
1403
|
+
: { from: currentContract.mount ?? nextContract.mount, to: nextContract.mount },
|
|
1404
|
+
resources: stringDiff(currentContract.resources, nextContract.resources, hasSnapshot),
|
|
1405
|
+
permissions: stringDiff(currentContract.permissions, nextContract.permissions, hasSnapshot),
|
|
1406
|
+
hooks: stringDiff(currentContract.hooks, nextContract.hooks, hasSnapshot),
|
|
1407
|
+
events: stringDiff(currentContract.events, nextContract.events, hasSnapshot),
|
|
1408
|
+
requires: stringDiff(currentContract.requires, nextContract.requires, hasSnapshot),
|
|
1409
|
+
secrets: {
|
|
1410
|
+
added: upgradeAvailable ? nextContract.secrets : [],
|
|
1411
|
+
removed: [],
|
|
1412
|
+
unchanged: upgradeAvailable ? [] : nextContract.secrets,
|
|
1413
|
+
snapshotAvailable: false,
|
|
1414
|
+
},
|
|
1415
|
+
};
|
|
1416
|
+
const customizations = lock.customizations ?? {};
|
|
1417
|
+
const customizationImpact = {
|
|
1418
|
+
configPreserved: customizations.config !== false,
|
|
1419
|
+
hooksToReview: module.hooks,
|
|
1420
|
+
overlaysToReview: matchingCustomizations(customizations.overlays, module.id),
|
|
1421
|
+
forksToReview: matchingCustomizations(customizations.forks, module.id),
|
|
1422
|
+
};
|
|
1423
|
+
const hasForks = customizationImpact.forksToReview.length > 0;
|
|
1424
|
+
const hasOverlays = customizationImpact.overlaysToReview.length > 0;
|
|
1425
|
+
const changesResources = diff.resources.added.length > 0 || diff.resources.removed.length > 0;
|
|
1426
|
+
const changesPermissions = diff.permissions.added.length > 0 || diff.permissions.removed.length > 0;
|
|
1427
|
+
const changesSecrets = diff.secrets.added.length > 0 || diff.secrets.removed.length > 0;
|
|
1428
|
+
const approvalRequired =
|
|
1429
|
+
upgradeAvailable &&
|
|
1430
|
+
(module.approvalRisk === "high" || changesSecrets || changesResources || changesPermissions || hasForks || hasOverlays);
|
|
1431
|
+
const risk = !upgradeAvailable
|
|
1432
|
+
? "low"
|
|
1433
|
+
: approvalRequired || hasForks
|
|
1434
|
+
? "high"
|
|
1435
|
+
: hasOverlays || diff.hooks.added.length || diff.hooks.removed.length
|
|
1436
|
+
? "medium"
|
|
1437
|
+
: "low";
|
|
1438
|
+
|
|
1439
|
+
return {
|
|
1440
|
+
module: {
|
|
1441
|
+
id: module.id,
|
|
1442
|
+
name: module.name,
|
|
1443
|
+
status: module.status,
|
|
1444
|
+
currentVersion: locked.version,
|
|
1445
|
+
targetVersion: module.version,
|
|
1446
|
+
},
|
|
1447
|
+
action: upgradeAvailable ? "upgrade-plan" : "no-op",
|
|
1448
|
+
upgradeAvailable,
|
|
1449
|
+
approvalRequired,
|
|
1450
|
+
risk,
|
|
1451
|
+
lockfile: {
|
|
1452
|
+
schemaVersion: lock.schemaVersion ?? null,
|
|
1453
|
+
registryVersion: moduleCatalog().schemaVersion,
|
|
1454
|
+
template: lock.template ?? null,
|
|
1455
|
+
source: locked.source ?? null,
|
|
1456
|
+
checksum: locked.checksum ?? null,
|
|
1457
|
+
contractSnapshotAvailable: hasSnapshot,
|
|
1458
|
+
},
|
|
1459
|
+
diff,
|
|
1460
|
+
customizationImpact,
|
|
1461
|
+
filesLikelyTouched: upgradeAvailable ? filesForUpgrade(module, diff) : [],
|
|
1462
|
+
permissionGate: {
|
|
1463
|
+
required: approvalRequired,
|
|
1464
|
+
reasons: [
|
|
1465
|
+
module.approvalRisk === "high" ? "high-risk module" : null,
|
|
1466
|
+
changesSecrets ? "secret changes" : null,
|
|
1467
|
+
changesResources ? "resource changes" : null,
|
|
1468
|
+
changesPermissions ? "permission changes" : null,
|
|
1469
|
+
hasOverlays ? "overlay customizations require merge review" : null,
|
|
1470
|
+
hasForks ? "forked module requires manual merge review" : null,
|
|
1471
|
+
].filter(Boolean),
|
|
1472
|
+
},
|
|
1473
|
+
nextSteps: upgradeAvailable
|
|
1474
|
+
? [
|
|
1475
|
+
"Review this plan with the user before modifying source.",
|
|
1476
|
+
"Create a branch or patch for the upgrade.",
|
|
1477
|
+
"Review hook, overlay, and fork impacts before applying generated changes.",
|
|
1478
|
+
"Run pnpm microservices check --json and pnpm typecheck after applying.",
|
|
1479
|
+
"Deploy preview only after approval for resources, migrations, webhooks, or secrets.",
|
|
1480
|
+
]
|
|
1481
|
+
: [
|
|
1482
|
+
"No upgrade is available from the current registry snapshot.",
|
|
1483
|
+
"Run microservices updates --json later to check again.",
|
|
1484
|
+
],
|
|
1485
|
+
};
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
export function validateConfig(input = {}) {
|
|
1490
|
+
return capture(() => {
|
|
1491
|
+
const composition = composeContractApp(input);
|
|
1492
|
+
const warnings = [];
|
|
1493
|
+
|
|
1494
|
+
if (composition.config.timezone === "UTC") {
|
|
1495
|
+
warnings.push("Timezone is still UTC. Set a business timezone before customer testing.");
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
if (composition.config.appName === "Booking Business") {
|
|
1499
|
+
warnings.push("App name is still the template default. Customize it for demos.");
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
return {
|
|
1503
|
+
valid: true,
|
|
1504
|
+
warnings,
|
|
1505
|
+
requiredBindings: composition.bindings,
|
|
1506
|
+
requiredStorage: composition.storage,
|
|
1507
|
+
customizationMode: composition.upgradePolicy.compatibleCustomization,
|
|
1508
|
+
};
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
export function generateProject(input = {}) {
|
|
1513
|
+
return capture(() => {
|
|
1514
|
+
const composition = composeContractApp(input);
|
|
1515
|
+
return {
|
|
1516
|
+
composition,
|
|
1517
|
+
files: buildProjectFiles(composition),
|
|
1518
|
+
nextSteps: [
|
|
1519
|
+
"Write files to a target directory.",
|
|
1520
|
+
"Run pnpm install in the generated project.",
|
|
1521
|
+
"Run pnpm db:init to initialize local D1.",
|
|
1522
|
+
"Run pnpm dev to start the generated Worker.",
|
|
1523
|
+
],
|
|
1524
|
+
};
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
export function runChecks(input = {}) {
|
|
1529
|
+
return capture(() => {
|
|
1530
|
+
const composition = composeContractApp(input);
|
|
1531
|
+
const checks = [
|
|
1532
|
+
{
|
|
1533
|
+
id: "module-contract",
|
|
1534
|
+
status: "pass",
|
|
1535
|
+
message: `${composition.modules.length} modules resolved with explicit contracts.`,
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
id: "dependency-resolution",
|
|
1539
|
+
status: "pass",
|
|
1540
|
+
message: `Resolved order: ${composition.modules.map((module) => module.id).join(" -> ")}.`,
|
|
1541
|
+
},
|
|
1542
|
+
{
|
|
1543
|
+
id: "worker-bindings",
|
|
1544
|
+
status: "pass",
|
|
1545
|
+
message: `Required bindings: ${composition.bindings.join(", ")}.`,
|
|
1546
|
+
},
|
|
1547
|
+
{
|
|
1548
|
+
id: "hook-surface",
|
|
1549
|
+
status: "pass",
|
|
1550
|
+
message: `${composition.hooks.length} typed customization hooks exposed.`,
|
|
1551
|
+
},
|
|
1552
|
+
{
|
|
1553
|
+
id: "preview-control-plane",
|
|
1554
|
+
status: "pass",
|
|
1555
|
+
message: "Preview deployment records and generated artifacts are supported by the API control plane.",
|
|
1556
|
+
},
|
|
1557
|
+
{
|
|
1558
|
+
id: "managed-cloudflare-provisioning",
|
|
1559
|
+
status: "pending",
|
|
1560
|
+
message: "Guarded D1/KV provisioning is wired; Worker upload and route activation remain pending.",
|
|
1561
|
+
},
|
|
1562
|
+
];
|
|
1563
|
+
|
|
1564
|
+
return {
|
|
1565
|
+
status: checks.every((check) => check.status === "pass") ? "pass" : "pending",
|
|
1566
|
+
checks,
|
|
1567
|
+
};
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
export function createMicroservicesClient() {
|
|
1572
|
+
return {
|
|
1573
|
+
listTemplates,
|
|
1574
|
+
inspectTemplate,
|
|
1575
|
+
listModules,
|
|
1576
|
+
inspectModule,
|
|
1577
|
+
listModuleDocs,
|
|
1578
|
+
getModuleDoc,
|
|
1579
|
+
planAddModule,
|
|
1580
|
+
getSecretsStatus,
|
|
1581
|
+
checkUpdates,
|
|
1582
|
+
planModuleUpgrade,
|
|
1583
|
+
composeApp,
|
|
1584
|
+
validateConfig,
|
|
1585
|
+
generateProject,
|
|
1586
|
+
runChecks,
|
|
1587
|
+
};
|
|
1588
|
+
}
|