@procedures/procedure 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +405 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +23 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +51 -0
- package/dist/module.js.map +1 -0
- package/dist/procedures/procedure/index.d.ts +5 -0
- package/dist/procedures/procedure/index.d.ts.map +1 -0
- package/dist/procedures/procedure/index.js +5 -0
- package/dist/procedures/procedure/index.js.map +1 -0
- package/dist/procedures/procedure/new.d.ts +55 -0
- package/dist/procedures/procedure/new.d.ts.map +1 -0
- package/dist/procedures/procedure/new.js +448 -0
- package/dist/procedures/procedure/new.js.map +1 -0
- package/dist/procedures/procedure/registry.d.ts +10 -0
- package/dist/procedures/procedure/registry.d.ts.map +1 -0
- package/dist/procedures/procedure/registry.js +68 -0
- package/dist/procedures/procedure/registry.js.map +1 -0
- package/dist/register.d.ts +7 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +9 -0
- package/dist/register.js.map +1 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +34 -0
- package/dist/types.js.map +1 -0
- package/package.json +75 -3
- package/src/index.ts +13 -0
- package/src/module.ts +61 -0
- package/src/procedures/procedure/index.ts +5 -0
- package/src/procedures/procedure/new.ts +502 -0
- package/src/procedures/procedure/registry.ts +93 -0
- package/src/register.ts +11 -0
- package/src/types.ts +63 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* procedure.new - Scaffold a new procedure
|
|
3
|
+
*
|
|
4
|
+
* Creates the procedure file, its types and its registration. A dot-notation name such as
|
|
5
|
+
* "user.create" makes the procedure at ["user", "create"]: the file is
|
|
6
|
+
* procedures/user/create.ts, and "api.v2.users.create" gives procedures/api/v2/users/create.ts.
|
|
7
|
+
*
|
|
8
|
+
* The scaffolded code compiles with the strict options of the workspace (deep dive CLI-13):
|
|
9
|
+
* - the handler names its unused input `_input` (noUnusedParameters);
|
|
10
|
+
* - types.ts is created, or gets `import { z } from "zod"`, when it needs it;
|
|
11
|
+
* - the registration goes into the defineModule([...]) array of src/module.ts. A package of the
|
|
12
|
+
* old kind, without module.ts, gets it in the registerProcedures([...]) array of src/register.ts.
|
|
13
|
+
* A name whose path, file, types or registration exists already is an error, and nothing is
|
|
14
|
+
* written. Before, "a.b.c" and "a.c" wrote the same file, and existing types were reused silently.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { PROCEDURE_REGISTRY, type ProcedureContext } from "@procedures/client";
|
|
19
|
+
import type { ProcedureNewInput, ProcedureNewOutput } from "../../types.js";
|
|
20
|
+
|
|
21
|
+
interface FsExistsOutput { exists: boolean; path: string; }
|
|
22
|
+
interface FsReadOutput { path: string; content: string; }
|
|
23
|
+
interface FsWriteOutput { path: string; bytesWritten: number; }
|
|
24
|
+
interface FsMkdirOutput { path: string; created: boolean; }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if path exists
|
|
28
|
+
*/
|
|
29
|
+
async function pathExists(pathStr: string, ctx: ProcedureContext): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
const result = await ctx.client.call<{ path: string }, FsExistsOutput>(
|
|
32
|
+
["fs", "exists"],
|
|
33
|
+
{ path: pathStr }
|
|
34
|
+
);
|
|
35
|
+
return result.exists;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readText(pathStr: string, ctx: ProcedureContext): Promise<string | undefined> {
|
|
42
|
+
if (!(await pathExists(pathStr, ctx))) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const result = await ctx.client.call<{ path: string }, FsReadOutput>(["fs", "read"], { path: pathStr });
|
|
46
|
+
return result.content;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function writeText(pathStr: string, content: string, ctx: ProcedureContext): Promise<void> {
|
|
50
|
+
await ctx.client.call<{ path: string; content: string }, FsWriteOutput>(["fs", "write"], { path: pathStr, content });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Convert camelCase to PascalCase
|
|
55
|
+
*/
|
|
56
|
+
function toPascalCase(str: string): string {
|
|
57
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert dot.notation to camelCase
|
|
62
|
+
*/
|
|
63
|
+
function toCamelCase(segments: string[]): string {
|
|
64
|
+
return segments
|
|
65
|
+
.map((s, i) => (i === 0 ? s : toPascalCase(s)))
|
|
66
|
+
.join("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The names and files of one procedure */
|
|
70
|
+
export interface ProcedureLayout {
|
|
71
|
+
segments: string[];
|
|
72
|
+
namespace: string;
|
|
73
|
+
/** The folders under procedures/<namespace>/ */
|
|
74
|
+
folders: string[];
|
|
75
|
+
/** The file name without ".ts" */
|
|
76
|
+
fileBase: string;
|
|
77
|
+
camelName: string;
|
|
78
|
+
pascalName: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The layout of a procedure: procedures/<namespace>/<middle segments>/<last segment>.ts
|
|
83
|
+
*/
|
|
84
|
+
export function procedureLayout(name: string, namespace?: string): ProcedureLayout {
|
|
85
|
+
const segments = name.split(".");
|
|
86
|
+
return {
|
|
87
|
+
segments,
|
|
88
|
+
namespace: namespace ?? segments[0]!,
|
|
89
|
+
folders: segments.length > 2 ? segments.slice(1, -1) : [],
|
|
90
|
+
fileBase: segments[segments.length - 1]!,
|
|
91
|
+
camelName: toCamelCase(segments),
|
|
92
|
+
pascalName: segments.map(toPascalCase).join(""),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Generate procedure file content
|
|
98
|
+
*/
|
|
99
|
+
function generateProcedureFile(layout: ProcedureLayout, description: string): string {
|
|
100
|
+
const { segments, camelName, pascalName, folders } = layout;
|
|
101
|
+
// procedures/<namespace>/<folders...>/<file>.ts -> src/types.ts
|
|
102
|
+
const typesPath = `${"../".repeat(2 + folders.length)}types.js`;
|
|
103
|
+
|
|
104
|
+
return `/**
|
|
105
|
+
* ${segments.join(".")} procedure
|
|
106
|
+
*
|
|
107
|
+
* ${description}
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
import type { ${pascalName}Input, ${pascalName}Output } from "${typesPath}";
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ${description}
|
|
114
|
+
*/
|
|
115
|
+
export async function ${camelName}(_input: ${pascalName}Input): Promise<${pascalName}Output> {
|
|
116
|
+
// TODO: Implement ${segments.join(".")} (rename _input to input when the code uses it)
|
|
117
|
+
return {
|
|
118
|
+
success: true,
|
|
119
|
+
message: "Hello from ${segments.join(".")}",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Generate type definitions
|
|
127
|
+
*/
|
|
128
|
+
function generateTypes(layout: ProcedureLayout, description: string): string {
|
|
129
|
+
const { segments, pascalName } = layout;
|
|
130
|
+
const schemaName = `${pascalName}InputSchema`;
|
|
131
|
+
const outputSchemaName = `${pascalName}OutputSchema`;
|
|
132
|
+
|
|
133
|
+
// The output has a real schema too: the registry validates each result with it. (Before, the
|
|
134
|
+
// output was an interface with a pass-through outputSchema(), so no result was checked.)
|
|
135
|
+
return `
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// ${segments.join(".")} Types - ${description}
|
|
138
|
+
// =============================================================================
|
|
139
|
+
|
|
140
|
+
export const ${schemaName}: z.ZodObject<{
|
|
141
|
+
// TODO: Add input fields
|
|
142
|
+
}> = z.object({
|
|
143
|
+
// TODO: Add input fields
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
export type ${pascalName}Input = z.infer<typeof ${schemaName}>;
|
|
147
|
+
|
|
148
|
+
export const ${outputSchemaName}: z.ZodObject<{
|
|
149
|
+
success: z.ZodBoolean;
|
|
150
|
+
message: z.ZodString;
|
|
151
|
+
}> = z.object({
|
|
152
|
+
/** Whether the operation succeeded */
|
|
153
|
+
success: z.boolean().describe("Whether the operation succeeded"),
|
|
154
|
+
/** Response message */
|
|
155
|
+
message: z.string().describe("Response message"),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export type ${pascalName}Output = z.infer<typeof ${outputSchemaName}>;
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const TYPES_HEADER = `/**
|
|
163
|
+
* Type definitions of the procedures of this package
|
|
164
|
+
*/
|
|
165
|
+
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
const Z_IMPORT = `import { z } from "zod";\n`;
|
|
169
|
+
|
|
170
|
+
/** True when the source imports z from "zod" */
|
|
171
|
+
function importsZ(source: string): boolean {
|
|
172
|
+
return (
|
|
173
|
+
/import\s*\{[^}]*\bz\b[^}]*\}\s*from\s*["']zod["']/.test(source) ||
|
|
174
|
+
/import\s+\*\s+as\s+z\s+from\s*["']zod["']/.test(source) ||
|
|
175
|
+
/import\s+z\s+from\s*["']zod["']/.test(source)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Add import lines after the last import of a source (or after its leading comment)
|
|
181
|
+
*/
|
|
182
|
+
export function addImports(source: string, lines: string[]): string {
|
|
183
|
+
if (lines.length === 0) {
|
|
184
|
+
return source;
|
|
185
|
+
}
|
|
186
|
+
const block = lines.join("");
|
|
187
|
+
const importPattern = /^import[\s\S]*?from\s*["'][^"']+["'];?[ \t]*\r?\n|^import\s*["'][^"']+["'];?[ \t]*\r?\n/gm;
|
|
188
|
+
let end = -1;
|
|
189
|
+
for (const match of source.matchAll(importPattern)) {
|
|
190
|
+
end = match.index + match[0].length;
|
|
191
|
+
}
|
|
192
|
+
if (end === -1) {
|
|
193
|
+
const comment = /^\s*\/\*[\s\S]*?\*\/[ \t]*\r?\n(\r?\n)?/.exec(source);
|
|
194
|
+
end = comment ? comment[0].length : 0;
|
|
195
|
+
}
|
|
196
|
+
return source.slice(0, end) + block + source.slice(end);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Add names to the value import from a module, or add the import
|
|
201
|
+
*/
|
|
202
|
+
export function ensureNamedImports(source: string, moduleName: string, names: string[]): string {
|
|
203
|
+
const escaped = moduleName.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
204
|
+
const pattern = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*["']${escaped}["'];?`);
|
|
205
|
+
const match = pattern.exec(source);
|
|
206
|
+
if (match && !/^import\s+type\b/.test(match[0])) {
|
|
207
|
+
const present = match[1]!
|
|
208
|
+
.split(",")
|
|
209
|
+
.map((part) => part.trim())
|
|
210
|
+
.filter((part) => part.length > 0);
|
|
211
|
+
const presentNames = new Set(present.map((part) => part.replace(/^type\s+/, "").split(/\s+as\s+/)[0]!.trim()));
|
|
212
|
+
const missing = names.filter((name) => !presentNames.has(name));
|
|
213
|
+
if (missing.length === 0) {
|
|
214
|
+
return source;
|
|
215
|
+
}
|
|
216
|
+
const replacement = `import { ${[...present, ...missing].join(", ")} } from "${moduleName}";`;
|
|
217
|
+
return source.slice(0, match.index) + replacement + source.slice(match.index + match[0].length);
|
|
218
|
+
}
|
|
219
|
+
return addImports(source, [`import { ${names.join(", ")} } from "${moduleName}";\n`]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The index of the bracket that closes the bracket at `open` (strings are skipped) */
|
|
223
|
+
function closingBracket(source: string, open: number): number {
|
|
224
|
+
let depth = 0;
|
|
225
|
+
let quote: string | undefined;
|
|
226
|
+
for (let i = open; i < source.length; i++) {
|
|
227
|
+
const ch = source[i]!;
|
|
228
|
+
if (quote) {
|
|
229
|
+
if (ch === "\\") i++;
|
|
230
|
+
else if (ch === quote) quote = undefined;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
234
|
+
else if (ch === "[") depth++;
|
|
235
|
+
else if (ch === "]" && --depth === 0) return i;
|
|
236
|
+
}
|
|
237
|
+
return -1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Add an item to the array of the first defineModule("name", [...]), registerProcedures([...]) or
|
|
242
|
+
* registerAll([...]) call. Returns undefined when the source has no such call.
|
|
243
|
+
*/
|
|
244
|
+
export function addToRegistration(source: string, item: string): string | undefined {
|
|
245
|
+
// The first call in code, not in a comment
|
|
246
|
+
const call = [...source.matchAll(/\b(defineModule|registerProcedures|registerAll)\s*\(/g)].find((match) => {
|
|
247
|
+
const lineStart = source.lastIndexOf("\n", match.index) + 1;
|
|
248
|
+
const before = source.slice(lineStart, match.index).trim();
|
|
249
|
+
return !before.startsWith("*") && !before.startsWith("/*") && !before.includes("//");
|
|
250
|
+
});
|
|
251
|
+
if (!call) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
const open = source.indexOf("[", call.index);
|
|
255
|
+
const close = open === -1 ? -1 : closingBracket(source, open);
|
|
256
|
+
if (close === -1) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
const content = source.slice(open + 1, close);
|
|
260
|
+
let updated: string;
|
|
261
|
+
if (content.trim() === "") {
|
|
262
|
+
updated = item;
|
|
263
|
+
} else if (content.includes("\n")) {
|
|
264
|
+
// One item per line: add a line with the indentation of the last item
|
|
265
|
+
const trimmed = content.replace(/\s*$/, "");
|
|
266
|
+
const lastLine = trimmed.slice(trimmed.lastIndexOf("\n") + 1);
|
|
267
|
+
const indent = /^\s*/.exec(lastLine)?.[0] ?? " ";
|
|
268
|
+
const comma = trimmed.endsWith(",") ? "" : ",";
|
|
269
|
+
updated = `${trimmed}${comma}\n${indent}${item},${content.slice(trimmed.length)}`;
|
|
270
|
+
} else {
|
|
271
|
+
updated = `${content.replace(/,?\s*$/, "")}, ${item}`;
|
|
272
|
+
}
|
|
273
|
+
return source.slice(0, open + 1) + updated + source.slice(close);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The procedure definition that goes into module.ts (or register.ts)
|
|
278
|
+
*/
|
|
279
|
+
function generateRegistration(layout: ProcedureLayout, description: string): string {
|
|
280
|
+
const { segments, camelName, pascalName } = layout;
|
|
281
|
+
return `const ${camelName}Procedure = createProcedure()
|
|
282
|
+
.path(${JSON.stringify(segments)})
|
|
283
|
+
.input(zodAdapter<${pascalName}Input>(${pascalName}InputSchema))
|
|
284
|
+
.output(zodAdapter<${pascalName}Output>(${pascalName}OutputSchema))
|
|
285
|
+
.meta({
|
|
286
|
+
description: ${JSON.stringify(description)},
|
|
287
|
+
args: [],
|
|
288
|
+
shorts: {},
|
|
289
|
+
output: "text",
|
|
290
|
+
})
|
|
291
|
+
.handler(async (input: ${pascalName}Input): Promise<${pascalName}Output> => ${camelName}(input))
|
|
292
|
+
.build();
|
|
293
|
+
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const REGISTER_TEMPLATE = `/**
|
|
298
|
+
* Procedure registration of this package
|
|
299
|
+
*
|
|
300
|
+
* Scaffold a procedure with: mark procedure new <name> --path <this package>
|
|
301
|
+
*/
|
|
302
|
+
|
|
303
|
+
import { registerProcedures } from "@procedures/client";
|
|
304
|
+
|
|
305
|
+
export function register(): void {
|
|
306
|
+
registerProcedures([]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Auto-register
|
|
310
|
+
register();
|
|
311
|
+
`;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Put the procedure into module.ts (or register.ts): its imports, its definition, and its place
|
|
315
|
+
* in the array of the module. Returns undefined when the file has no such array.
|
|
316
|
+
*/
|
|
317
|
+
export function registerSource(source: string, layout: ProcedureLayout, description: string, importPath: string): string | undefined {
|
|
318
|
+
const { camelName, pascalName } = layout;
|
|
319
|
+
const withArray = addToRegistration(source, `${camelName}Procedure`);
|
|
320
|
+
if (withArray === undefined) {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
let updated = ensureNamedImports(withArray, "@procedures/client", ["createProcedure", "zodAdapter"]);
|
|
324
|
+
updated = addImports(updated, [
|
|
325
|
+
`import { ${camelName} } from "${importPath}";\n`,
|
|
326
|
+
`import { ${pascalName}InputSchema, ${pascalName}OutputSchema, type ${pascalName}Input, type ${pascalName}Output } from "./types.js";\n`,
|
|
327
|
+
]);
|
|
328
|
+
// The definition goes before the module, or before the code that registers the procedures
|
|
329
|
+
const anchor =
|
|
330
|
+
/^export\s+const\s+\w+\s*(:\s*\w+\s*)?=\s*defineModule\s*\(|^(export\s+)?(async\s+)?function\s+register\w*\s*\(|^\s*(registerProcedures|PROCEDURE_REGISTRY\.registerAll)\s*\(/m.exec(
|
|
331
|
+
updated
|
|
332
|
+
);
|
|
333
|
+
const at = anchor ? anchor.index : updated.length;
|
|
334
|
+
return updated.slice(0, at) + generateRegistration(layout, description) + updated.slice(at);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Generate index.ts export
|
|
339
|
+
*/
|
|
340
|
+
function generateIndexExport(relativeFile: string, functionName: string): string {
|
|
341
|
+
return `export { ${functionName} } from "./${relativeFile}.js";\n`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Scaffold a new procedure
|
|
346
|
+
*/
|
|
347
|
+
export async function procedureNew(input: ProcedureNewInput, ctx: ProcedureContext): Promise<ProcedureNewOutput> {
|
|
348
|
+
const operations: string[] = [];
|
|
349
|
+
const created: string[] = [];
|
|
350
|
+
const modified: string[] = [];
|
|
351
|
+
const errors: string[] = [];
|
|
352
|
+
|
|
353
|
+
const layout = procedureLayout(input.name, input.namespace);
|
|
354
|
+
const { segments, namespace, folders, fileBase, camelName, pascalName } = layout;
|
|
355
|
+
|
|
356
|
+
// Resolve paths
|
|
357
|
+
const projectPath = input.path ?? process.cwd();
|
|
358
|
+
const srcPath = join(projectPath, "src");
|
|
359
|
+
const namespacePath = join(srcPath, "procedures", namespace);
|
|
360
|
+
const procedureDir = join(namespacePath, ...folders);
|
|
361
|
+
const procedureFile = join(procedureDir, `${fileBase}.ts`);
|
|
362
|
+
const namespaceIndex = join(namespacePath, "index.ts");
|
|
363
|
+
const typesFile = join(srcPath, "types.ts");
|
|
364
|
+
const moduleFile = join(srcPath, "module.ts");
|
|
365
|
+
const relativeFile = [...folders, fileBase].join("/");
|
|
366
|
+
const importPath = `./procedures/${namespace}/${relativeFile}.js`;
|
|
367
|
+
|
|
368
|
+
const description = input.description ?? `${segments.join(".")} procedure`;
|
|
369
|
+
const fail = (message: string): ProcedureNewOutput => ({
|
|
370
|
+
success: false,
|
|
371
|
+
procedurePath: segments,
|
|
372
|
+
created,
|
|
373
|
+
modified,
|
|
374
|
+
operations,
|
|
375
|
+
errors: [...errors, message],
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Check every collision before anything is written
|
|
379
|
+
// The registry of the invocation (a host with its own registry), else the global one
|
|
380
|
+
if ((ctx.registry ?? PROCEDURE_REGISTRY).has(segments)) {
|
|
381
|
+
return fail(`A procedure is registered at ${segments.join(".")} already. Choose another name.`);
|
|
382
|
+
}
|
|
383
|
+
if (await pathExists(procedureFile, ctx)) {
|
|
384
|
+
return fail(`Procedure file already exists: ${procedureFile}`);
|
|
385
|
+
}
|
|
386
|
+
const existingTypes = await readText(typesFile, ctx);
|
|
387
|
+
if (existingTypes !== undefined && new RegExp(`\\b${pascalName}(Input|Output|InputSchema|OutputSchema)\\b`).test(existingTypes)) {
|
|
388
|
+
return fail(
|
|
389
|
+
`types.ts already has types named ${pascalName}: the name ${segments.join(".")} collides with another procedure. Choose another name.`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
// The procedures go into module.ts. A package of the old kind, without it, has them in register.ts.
|
|
393
|
+
const existingModule = await readText(moduleFile, ctx);
|
|
394
|
+
const registerFile = existingModule !== undefined ? moduleFile : join(srcPath, "register.ts");
|
|
395
|
+
const registerName = existingModule !== undefined ? "module.ts" : "register.ts";
|
|
396
|
+
const existingRegister = existingModule ?? (await readText(registerFile, ctx));
|
|
397
|
+
if (existingRegister !== undefined) {
|
|
398
|
+
if (existingRegister.includes(JSON.stringify(segments)) || new RegExp(`\\b${camelName}Procedure\\b`).test(existingRegister)) {
|
|
399
|
+
return fail(`${registerName} already registers ${segments.join(".")} (or ${camelName}Procedure). Choose another name.`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const registerBase = existingRegister ?? REGISTER_TEMPLATE;
|
|
403
|
+
const newRegister = registerSource(registerBase, layout, description, importPath);
|
|
404
|
+
|
|
405
|
+
if (input.dryRun) {
|
|
406
|
+
const [indexExists] = await Promise.all([pathExists(namespaceIndex, ctx)]);
|
|
407
|
+
return {
|
|
408
|
+
success: true,
|
|
409
|
+
procedurePath: segments,
|
|
410
|
+
created: [
|
|
411
|
+
procedureFile,
|
|
412
|
+
...(indexExists ? [] : [namespaceIndex]),
|
|
413
|
+
...(existingTypes === undefined ? [typesFile] : []),
|
|
414
|
+
...(existingRegister === undefined ? [registerFile] : []),
|
|
415
|
+
],
|
|
416
|
+
modified: [
|
|
417
|
+
...(indexExists ? [namespaceIndex] : []),
|
|
418
|
+
...(existingTypes !== undefined ? [typesFile] : []),
|
|
419
|
+
...(existingRegister !== undefined && newRegister !== undefined ? [registerFile] : []),
|
|
420
|
+
],
|
|
421
|
+
operations: [
|
|
422
|
+
`Would create procedure file: ${procedureFile}`,
|
|
423
|
+
`Would create/update index: ${namespaceIndex}`,
|
|
424
|
+
existingTypes === undefined ? `Would create ${typesFile}` : `Would append types to: ${typesFile}`,
|
|
425
|
+
newRegister === undefined
|
|
426
|
+
? `Would not change ${registerFile}: it has no defineModule or registerProcedures array`
|
|
427
|
+
: `Would register the procedure in: ${registerFile}`,
|
|
428
|
+
],
|
|
429
|
+
errors: [],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
// Step 1: Create the folder and the procedure file
|
|
435
|
+
if (!(await pathExists(procedureDir, ctx))) {
|
|
436
|
+
operations.push(`Creating directory: ${procedureDir}`);
|
|
437
|
+
await ctx.client.call<{ path: string; recursive?: boolean }, FsMkdirOutput>(
|
|
438
|
+
["fs", "mkdir"],
|
|
439
|
+
{ path: procedureDir, recursive: true }
|
|
440
|
+
);
|
|
441
|
+
created.push(procedureDir);
|
|
442
|
+
}
|
|
443
|
+
operations.push(`Creating procedure file: ${procedureFile}`);
|
|
444
|
+
await writeText(procedureFile, generateProcedureFile(layout, description), ctx);
|
|
445
|
+
created.push(procedureFile);
|
|
446
|
+
|
|
447
|
+
// Step 2: Create/update namespace index.ts
|
|
448
|
+
const indexExport = generateIndexExport(relativeFile, camelName);
|
|
449
|
+
const existingIndex = await readText(namespaceIndex, ctx);
|
|
450
|
+
if (existingIndex !== undefined) {
|
|
451
|
+
if (!existingIndex.includes(`from "./${relativeFile}.js"`)) {
|
|
452
|
+
operations.push(`Updating index: ${namespaceIndex}`);
|
|
453
|
+
await writeText(namespaceIndex, existingIndex + indexExport, ctx);
|
|
454
|
+
modified.push(namespaceIndex);
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
operations.push(`Creating index: ${namespaceIndex}`);
|
|
458
|
+
await writeText(namespaceIndex, indexExport, ctx);
|
|
459
|
+
created.push(namespaceIndex);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Step 3: Add the types (create types.ts, or add the z import that the types need)
|
|
463
|
+
const newTypes = generateTypes(layout, description);
|
|
464
|
+
if (existingTypes === undefined) {
|
|
465
|
+
operations.push(`Creating types: ${typesFile}`);
|
|
466
|
+
await writeText(typesFile, TYPES_HEADER + Z_IMPORT + newTypes, ctx);
|
|
467
|
+
created.push(typesFile);
|
|
468
|
+
} else {
|
|
469
|
+
operations.push(`Appending types to: ${typesFile}`);
|
|
470
|
+
const withImport = importsZ(existingTypes) ? existingTypes : addImports(existingTypes, [Z_IMPORT]);
|
|
471
|
+
await writeText(typesFile, withImport + newTypes, ctx);
|
|
472
|
+
modified.push(typesFile);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Step 4: Register the procedure
|
|
476
|
+
if (newRegister === undefined) {
|
|
477
|
+
operations.push(
|
|
478
|
+
`${registerName} has no defineModule or registerProcedures array: add ${camelName}Procedure to the module in ${registerFile}`
|
|
479
|
+
);
|
|
480
|
+
} else {
|
|
481
|
+
operations.push(`${existingRegister === undefined ? "Creating" : "Updating"} registration: ${registerFile}`);
|
|
482
|
+
await writeText(registerFile, newRegister, ctx);
|
|
483
|
+
(existingRegister === undefined ? created : modified).push(registerFile);
|
|
484
|
+
if (existingRegister === undefined) {
|
|
485
|
+
operations.push(`Export the procedures from src/index.ts, and give the package a "./register" entry (see lib new)`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
operations.push(`Next: implement ${segments.join(".")} in ${procedureFile}, then run pnpm build`);
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
success: errors.length === 0,
|
|
493
|
+
procedurePath: segments,
|
|
494
|
+
created,
|
|
495
|
+
modified,
|
|
496
|
+
operations,
|
|
497
|
+
errors,
|
|
498
|
+
};
|
|
499
|
+
} catch (error) {
|
|
500
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* procedure.* Registry Procedures
|
|
3
|
+
*
|
|
4
|
+
* Procedures for managing the procedure registry dynamically.
|
|
5
|
+
* Enables procedure-as-data patterns, remote registration, and persistence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineProcedure, PROCEDURE_REGISTRY, REGISTRY_READ_EFFECTS, pathToKey, keyToPath } from "@procedures/client";
|
|
9
|
+
import type { AnyProcedure, ProcedurePath } from "@procedures/client";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
// procedure.list and procedure.get are core procedures (`@procedures/client`, in
|
|
13
|
+
// define-procedure.ts). This package gave its own versions for the whole registry and replaced
|
|
14
|
+
// the core ones, so the results depended on the imports of the host (docs review DOC-6). The core
|
|
15
|
+
// versions now list and describe the whole registry of the caller, with a `runtime` flag.
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// procedure.export - Export procedure as JSON
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
const ProcedureExportInputSchema = z.object({
|
|
22
|
+
/** Procedure path (array or dot-separated) */
|
|
23
|
+
path: z.union([z.array(z.string()), z.string()]).describe("Procedure path (array or dot-separated)"),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
type ProcedureExportInput = z.infer<typeof ProcedureExportInputSchema>;
|
|
27
|
+
|
|
28
|
+
interface ProcedureExportOutput {
|
|
29
|
+
/** Whether export succeeded */
|
|
30
|
+
success: boolean;
|
|
31
|
+
/** Exported procedure definition */
|
|
32
|
+
definition?: {
|
|
33
|
+
$proc: ProcedurePath;
|
|
34
|
+
metadata?: Record<string, unknown> | undefined;
|
|
35
|
+
};
|
|
36
|
+
/** Error if failed */
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The output of `procedure.export` (roadmap 2.1: before, a pass-through schema) */
|
|
41
|
+
const ProcedureExportOutputSchema = z.looseObject({
|
|
42
|
+
success: z.boolean().describe("True when the registry has the procedure"),
|
|
43
|
+
definition: z
|
|
44
|
+
.looseObject({
|
|
45
|
+
$proc: z.array(z.string()).describe("The path of the procedure"),
|
|
46
|
+
metadata: z.record(z.string(), z.unknown()).optional().describe("The metadata of the procedure"),
|
|
47
|
+
})
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("The procedure as a procedure reference, when it exists"),
|
|
50
|
+
error: z.string().optional().describe("The error message when the registry has no such procedure"),
|
|
51
|
+
}) as z.ZodType<ProcedureExportOutput>;
|
|
52
|
+
|
|
53
|
+
export const procedureExportProcedure: AnyProcedure = defineProcedure({
|
|
54
|
+
path: ["procedure", "export"],
|
|
55
|
+
input: ProcedureExportInputSchema,
|
|
56
|
+
output: ProcedureExportOutputSchema,
|
|
57
|
+
metadata: {
|
|
58
|
+
description: "Export a procedure definition as JSON",
|
|
59
|
+
tags: ["procedure", "registry"],
|
|
60
|
+
effects: REGISTRY_READ_EFFECTS,
|
|
61
|
+
args: ["path"],
|
|
62
|
+
shorts: {},
|
|
63
|
+
output: "json",
|
|
64
|
+
},
|
|
65
|
+
handler: async (input: ProcedureExportInput): Promise<ProcedureExportOutput> => {
|
|
66
|
+
const path = typeof input.path === "string"
|
|
67
|
+
? keyToPath(input.path)
|
|
68
|
+
: input.path;
|
|
69
|
+
|
|
70
|
+
const proc = PROCEDURE_REGISTRY.get(path);
|
|
71
|
+
|
|
72
|
+
if (!proc) {
|
|
73
|
+
return {
|
|
74
|
+
success: false,
|
|
75
|
+
error: `Procedure not found: ${pathToKey(path)}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
success: true,
|
|
81
|
+
definition: {
|
|
82
|
+
$proc: proc.path,
|
|
83
|
+
metadata: proc.metadata as Record<string, unknown> | undefined,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// =============================================================================
|
|
90
|
+
// Export all procedure.* procedures
|
|
91
|
+
// =============================================================================
|
|
92
|
+
|
|
93
|
+
export const procedureRegistryProcedures: AnyProcedure[] = [procedureExportProcedure];
|
package/src/register.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importing this file registers the procedures of @procedures/procedure, and of the modules that it
|
|
3
|
+
* requires, in the global registry: `import "@procedures/procedure/register"`. The package root and the
|
|
4
|
+
* `./module` entry register nothing (ARCHITECTURE-PROPOSALS-2026-10, P9).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { registerProcedureProcedures } from "./module.js";
|
|
8
|
+
|
|
9
|
+
export * from "./module.js";
|
|
10
|
+
|
|
11
|
+
registerProcedureProcedures();
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for client-procedure
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// procedure.new Types
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
export const ProcedureNewInputSchema: z.ZodObject<{
|
|
12
|
+
name: z.ZodString;
|
|
13
|
+
namespace: z.ZodOptional<z.ZodString>;
|
|
14
|
+
description: z.ZodOptional<z.ZodString>;
|
|
15
|
+
path: z.ZodOptional<z.ZodString>;
|
|
16
|
+
dryRun: z.ZodDefault<z.ZodBoolean>;
|
|
17
|
+
}> = z.object({
|
|
18
|
+
/** Procedure name (e.g., "greet", "user.create" or "fs.readJson") */
|
|
19
|
+
name: z
|
|
20
|
+
.string()
|
|
21
|
+
.regex(
|
|
22
|
+
/^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9]*)*$/,
|
|
23
|
+
"Name must be dot-separated segments, each starting with a lowercase letter (camelCase is allowed)"
|
|
24
|
+
).describe("Procedure name (e.g., \"greet\", \"user.create\" or \"fs.readJson\")"),
|
|
25
|
+
/** Namespace override (defaults to first segment of name): the folder under src/procedures */
|
|
26
|
+
namespace: z
|
|
27
|
+
.string()
|
|
28
|
+
.regex(/^[a-z][a-zA-Z0-9]*$/, "Namespace must be one segment that starts with a lowercase letter")
|
|
29
|
+
.optional().describe("Namespace override (defaults to first segment of name): the folder under src/procedures"),
|
|
30
|
+
/** Procedure description for CLI help */
|
|
31
|
+
description: z.string().optional().describe("Procedure description for CLI help"),
|
|
32
|
+
/** Project path (defaults to cwd) */
|
|
33
|
+
path: z.string().optional().describe("Project path (defaults to cwd)"),
|
|
34
|
+
/** Preview changes without creating */
|
|
35
|
+
dryRun: z.boolean().default(false).describe("Preview changes without creating"),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type ProcedureNewInput = z.infer<typeof ProcedureNewInputSchema>;
|
|
39
|
+
|
|
40
|
+
export interface ProcedureNewOutput {
|
|
41
|
+
/** Whether creation succeeded */
|
|
42
|
+
success: boolean;
|
|
43
|
+
/** Full procedure path (e.g., ["user", "create"]) */
|
|
44
|
+
procedurePath: string[];
|
|
45
|
+
/** Files created */
|
|
46
|
+
created: string[];
|
|
47
|
+
/** Files modified */
|
|
48
|
+
modified: string[];
|
|
49
|
+
/** Operations performed */
|
|
50
|
+
operations: string[];
|
|
51
|
+
/** Any errors encountered */
|
|
52
|
+
errors: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The output schema of procedure.new (roadmap 2.1). Loose: a field that it does not name stays. */
|
|
56
|
+
export const ProcedureNewOutputSchema: z.ZodType<ProcedureNewOutput> = z.looseObject({
|
|
57
|
+
success: z.boolean().describe("True when the procedure was made"),
|
|
58
|
+
procedurePath: z.array(z.string()).describe('The path of the new procedure, for example ["user", "create"]'),
|
|
59
|
+
created: z.array(z.string()).describe("The files that the call made"),
|
|
60
|
+
modified: z.array(z.string()).describe("The files that the call changed (types.ts, register.ts)"),
|
|
61
|
+
operations: z.array(z.string()).describe("The steps that the call ran"),
|
|
62
|
+
errors: z.array(z.string()).describe("The errors"),
|
|
63
|
+
});
|