@ontrails/trails 1.0.0-beta.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.
Files changed (83) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +12 -0
  5. package/__tests__/examples.test.ts +6 -0
  6. package/bin/trails.ts +3 -0
  7. package/dist/bin/trails.d.ts +3 -0
  8. package/dist/bin/trails.d.ts.map +1 -0
  9. package/dist/bin/trails.js +4 -0
  10. package/dist/bin/trails.js.map +1 -0
  11. package/dist/src/app.d.ts +2 -0
  12. package/dist/src/app.d.ts.map +1 -0
  13. package/dist/src/app.js +11 -0
  14. package/dist/src/app.js.map +1 -0
  15. package/dist/src/clack.d.ts +9 -0
  16. package/dist/src/clack.d.ts.map +1 -0
  17. package/dist/src/clack.js +62 -0
  18. package/dist/src/clack.js.map +1 -0
  19. package/dist/src/cli.d.ts +2 -0
  20. package/dist/src/cli.d.ts.map +1 -0
  21. package/dist/src/cli.js +13 -0
  22. package/dist/src/cli.js.map +1 -0
  23. package/dist/src/trails/add-surface.d.ts +13 -0
  24. package/dist/src/trails/add-surface.d.ts.map +1 -0
  25. package/dist/src/trails/add-surface.js +88 -0
  26. package/dist/src/trails/add-surface.js.map +1 -0
  27. package/dist/src/trails/add-trail.d.ts +11 -0
  28. package/dist/src/trails/add-trail.d.ts.map +1 -0
  29. package/dist/src/trails/add-trail.js +85 -0
  30. package/dist/src/trails/add-trail.js.map +1 -0
  31. package/dist/src/trails/add-verify.d.ts +10 -0
  32. package/dist/src/trails/add-verify.d.ts.map +1 -0
  33. package/dist/src/trails/add-verify.js +67 -0
  34. package/dist/src/trails/add-verify.js.map +1 -0
  35. package/dist/src/trails/create-scaffold.d.ts +15 -0
  36. package/dist/src/trails/create-scaffold.d.ts.map +1 -0
  37. package/dist/src/trails/create-scaffold.js +288 -0
  38. package/dist/src/trails/create-scaffold.js.map +1 -0
  39. package/dist/src/trails/create.d.ts +22 -0
  40. package/dist/src/trails/create.d.ts.map +1 -0
  41. package/dist/src/trails/create.js +121 -0
  42. package/dist/src/trails/create.js.map +1 -0
  43. package/dist/src/trails/guide.d.ts +11 -0
  44. package/dist/src/trails/guide.d.ts.map +1 -0
  45. package/dist/src/trails/guide.js +80 -0
  46. package/dist/src/trails/guide.js.map +1 -0
  47. package/dist/src/trails/load-app.d.ts +4 -0
  48. package/dist/src/trails/load-app.d.ts.map +1 -0
  49. package/dist/src/trails/load-app.js +24 -0
  50. package/dist/src/trails/load-app.js.map +1 -0
  51. package/dist/src/trails/project.d.ts +8 -0
  52. package/dist/src/trails/project.d.ts.map +1 -0
  53. package/dist/src/trails/project.js +43 -0
  54. package/dist/src/trails/project.js.map +1 -0
  55. package/dist/src/trails/survey.d.ts +33 -0
  56. package/dist/src/trails/survey.d.ts.map +1 -0
  57. package/dist/src/trails/survey.js +225 -0
  58. package/dist/src/trails/survey.js.map +1 -0
  59. package/dist/src/trails/warden.d.ts +19 -0
  60. package/dist/src/trails/warden.d.ts.map +1 -0
  61. package/dist/src/trails/warden.js +88 -0
  62. package/dist/src/trails/warden.js.map +1 -0
  63. package/dist/tsconfig.tsbuildinfo +1 -0
  64. package/package.json +28 -0
  65. package/src/__tests__/create.test.ts +349 -0
  66. package/src/__tests__/guide.test.ts +91 -0
  67. package/src/__tests__/load-app.test.ts +15 -0
  68. package/src/__tests__/survey.test.ts +161 -0
  69. package/src/__tests__/warden.test.ts +74 -0
  70. package/src/app.ts +22 -0
  71. package/src/clack.ts +89 -0
  72. package/src/cli.ts +14 -0
  73. package/src/trails/add-surface.ts +119 -0
  74. package/src/trails/add-trail.ts +103 -0
  75. package/src/trails/add-verify.ts +87 -0
  76. package/src/trails/create-scaffold.ts +352 -0
  77. package/src/trails/create.ts +203 -0
  78. package/src/trails/guide.ts +104 -0
  79. package/src/trails/load-app.ts +37 -0
  80. package/src/trails/project.ts +51 -0
  81. package/src/trails/survey.ts +307 -0
  82. package/src/trails/warden.ts +104 -0
  83. package/tsconfig.json +9 -0
package/src/app.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { topo } from '@ontrails/core';
2
+
3
+ import * as addSurface from './trails/add-surface.js';
4
+ import * as addTrail from './trails/add-trail.js';
5
+ import * as addVerify from './trails/add-verify.js';
6
+ import * as create from './trails/create.js';
7
+ import * as createScaffold from './trails/create-scaffold.js';
8
+ import * as guide from './trails/guide.js';
9
+ import * as survey from './trails/survey.js';
10
+ import * as warden from './trails/warden.js';
11
+
12
+ export const app = topo(
13
+ 'trails',
14
+ survey,
15
+ guide,
16
+ warden,
17
+ create,
18
+ createScaffold,
19
+ addSurface,
20
+ addVerify,
21
+ addTrail
22
+ );
package/src/clack.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Clack-backed input resolver for the Trails CLI.
3
+ *
4
+ * This stays at the app layer so @ontrails/cli remains prompt-library agnostic.
5
+ */
6
+
7
+ import type { Field, InputResolver, ResolveInputOptions } from '@ontrails/cli';
8
+ import { isInteractive } from '@ontrails/cli';
9
+ import * as clack from '@clack/prompts';
10
+
11
+ /** Check whether a field still needs input. */
12
+ const needsInput = (field: Field, current: Record<string, unknown>): boolean =>
13
+ current[field.name] === undefined &&
14
+ field.required &&
15
+ field.default === undefined;
16
+
17
+ /** Build Clack options from field options. */
18
+ const toClackOptions = (field: Field) =>
19
+ field.options?.map((option) => ({
20
+ ...(option.hint === undefined ? {} : { hint: option.hint }),
21
+ label: option.label ?? option.value,
22
+ value: option.value,
23
+ })) ?? [];
24
+
25
+ /** Normalize cancelled prompts to `undefined`. */
26
+ const cancelable = async <T>(value: T | symbol): Promise<T | undefined> =>
27
+ await (clack.isCancel(value) ? undefined : value);
28
+
29
+ type FieldResolver = (field: Field) => Promise<unknown>;
30
+
31
+ const fieldResolvers: Record<Field['type'], FieldResolver> = {
32
+ boolean: async (field) =>
33
+ cancelable(
34
+ await clack.confirm({
35
+ initialValue: (field.default as boolean | undefined) ?? false,
36
+ message: field.label,
37
+ })
38
+ ),
39
+ enum: async (field) =>
40
+ cancelable(
41
+ await clack.select({
42
+ message: field.label,
43
+ options: toClackOptions(field),
44
+ })
45
+ ),
46
+ multiselect: async (field) =>
47
+ cancelable(
48
+ await clack.multiselect({
49
+ initialValues: (field.default as string[] | undefined) ?? [],
50
+ message: field.label,
51
+ options: toClackOptions(field),
52
+ })
53
+ ),
54
+ number: async (field) => {
55
+ const raw = await clack.text({ message: field.label });
56
+ return clack.isCancel(raw) ? undefined : Number(raw);
57
+ },
58
+ string: async (field) =>
59
+ cancelable(await clack.text({ message: field.label })),
60
+ };
61
+
62
+ /** Resolve a single field value with Clack. */
63
+ const resolveField = (field: Field): Promise<unknown> => {
64
+ const resolver = fieldResolvers[field.type];
65
+ return resolver(field);
66
+ };
67
+
68
+ /** Fill missing input by prompting with Clack when interactive. */
69
+ export const resolveInputWithClack: InputResolver = async (
70
+ fields,
71
+ provided,
72
+ options?: ResolveInputOptions
73
+ ) => {
74
+ if (!isInteractive(options)) {
75
+ return provided;
76
+ }
77
+
78
+ const resolved: Record<string, unknown> = { ...provided };
79
+ for (const field of fields) {
80
+ if (!needsInput(field, resolved)) {
81
+ continue;
82
+ }
83
+ const value = await resolveField(field);
84
+ if (value !== undefined) {
85
+ resolved[field.name] = value;
86
+ }
87
+ }
88
+ return resolved;
89
+ };
package/src/cli.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { outputModePreset } from '@ontrails/cli';
2
+ import { blaze } from '@ontrails/cli/commander';
3
+
4
+ import { app } from './app.js';
5
+ import { resolveInputWithClack } from './clack.js';
6
+
7
+ // oxlint-disable-next-line require-hook -- CLI entry point
8
+ blaze(app, {
9
+ description: 'Agent-native, contract-first TypeScript framework',
10
+ name: 'trails',
11
+ presets: [outputModePreset()],
12
+ resolveInput: resolveInputWithClack,
13
+ version: '0.1.0',
14
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `add.surface` trail -- Add a surface to an existing project.
3
+ *
4
+ * Generates the CLI or MCP entry point and updates package.json dependencies.
5
+ */
6
+
7
+ import { existsSync, mkdirSync } from 'node:fs';
8
+ import { basename, dirname, join, resolve } from 'node:path';
9
+
10
+ import { Result, trail } from '@ontrails/core';
11
+ import { z } from 'zod';
12
+
13
+ import { findTopoPath } from './project.js';
14
+
15
+ const generateCliEntry = (appImportPath: string): string =>
16
+ `import { blaze } from '@ontrails/cli/commander';
17
+
18
+ import { app } from '${appImportPath}';
19
+
20
+ blaze(app);
21
+ `;
22
+
23
+ const generateMcpEntry = (appImportPath: string): string =>
24
+ `import { blaze } from '@ontrails/mcp';
25
+
26
+ import { app } from '${appImportPath}';
27
+
28
+ await blaze(app);
29
+ `;
30
+
31
+ /** Resolve the entry file for a surface. */
32
+ const getEntryFile = (surface: 'cli' | 'mcp'): string =>
33
+ surface === 'cli' ? 'src/cli.ts' : 'src/mcp.ts';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Trail definition
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Patch deps and optionally bin in a parsed package.json. */
40
+ const patchPkgDeps = (
41
+ pkg: Record<string, unknown>,
42
+ surface: 'cli' | 'mcp',
43
+ cwd: string
44
+ ): string => {
45
+ const depName = surface === 'cli' ? '@ontrails/cli' : '@ontrails/mcp';
46
+ const deps = (pkg['dependencies'] ?? {}) as Record<string, string>;
47
+ deps[depName] = 'workspace:*';
48
+ if (surface === 'cli') {
49
+ deps['commander'] = '^14.0.0';
50
+ pkg['bin'] = {
51
+ [(pkg['name'] as string | undefined) ?? basename(cwd)]: './src/cli.ts',
52
+ };
53
+ }
54
+ pkg['dependencies'] = Object.fromEntries(
55
+ Object.entries(deps).toSorted(([a], [b]) => a.localeCompare(b))
56
+ );
57
+ return depName;
58
+ };
59
+
60
+ /** Update package.json with surface dependency and CLI bin if needed. */
61
+ const updatePkgJsonForSurface = async (
62
+ cwd: string,
63
+ surface: 'cli' | 'mcp'
64
+ ): Promise<string> => {
65
+ const pkgPath = join(cwd, 'package.json');
66
+ if (!existsSync(pkgPath)) {
67
+ return surface === 'cli' ? '@ontrails/cli' : '@ontrails/mcp';
68
+ }
69
+ const pkg = (await Bun.file(pkgPath).json()) as Record<string, unknown>;
70
+ const depName = patchPkgDeps(pkg, surface, cwd);
71
+ await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
72
+ return depName;
73
+ };
74
+
75
+ /** Create the entry file for a surface and return the relative path. */
76
+ const writeSurfaceEntry = async (
77
+ cwd: string,
78
+ surface: 'cli' | 'mcp'
79
+ ): Promise<string> => {
80
+ const entryFile = getEntryFile(surface);
81
+ const fullEntryPath = join(cwd, entryFile);
82
+ const appImport = (await findTopoPath(cwd)) ?? './app.js';
83
+ const content =
84
+ surface === 'cli'
85
+ ? generateCliEntry(appImport)
86
+ : generateMcpEntry(appImport);
87
+
88
+ mkdirSync(dirname(fullEntryPath), { recursive: true });
89
+ await Bun.write(fullEntryPath, content);
90
+ return entryFile;
91
+ };
92
+
93
+ export const addSurface = trail('add.surface', {
94
+ description: 'Add a surface to an existing project',
95
+ implementation: async (input) => {
96
+ const cwd = resolve(input.dir ?? '.');
97
+ const { surface } = input;
98
+ const entryFile = getEntryFile(surface);
99
+
100
+ if (existsSync(join(cwd, entryFile))) {
101
+ return Result.err(
102
+ new Error(`${surface.toUpperCase()} is already blazed. Nothing to do.`)
103
+ );
104
+ }
105
+
106
+ return Result.ok({
107
+ created: await writeSurfaceEntry(cwd, surface),
108
+ dependency: await updatePkgJsonForSurface(cwd, surface),
109
+ });
110
+ },
111
+ input: z.object({
112
+ dir: z.string().optional().describe('Project directory'),
113
+ surface: z.enum(['cli', 'mcp']).describe('Surface to add'),
114
+ }),
115
+ output: z.object({
116
+ created: z.string(),
117
+ dependency: z.string(),
118
+ }),
119
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `add.trail` trail -- Scaffold a new trail file with tests.
3
+ */
4
+
5
+ import { mkdirSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+
8
+ import { Result, trail } from '@ontrails/core';
9
+ import { z } from 'zod';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const generateTrailFile = (
16
+ id: string,
17
+ readOnly: boolean,
18
+ destructive: boolean
19
+ ): string => {
20
+ const markers: string[] = [];
21
+ if (readOnly) {
22
+ markers.push(' readOnly: true,');
23
+ }
24
+ if (destructive) {
25
+ markers.push(' destructive: true,');
26
+ }
27
+ const markerBlock = markers.length > 0 ? `\n${markers.join('\n')}` : '';
28
+
29
+ return `import { Result, trail } from '@ontrails/core';
30
+ import { z } from 'zod';
31
+
32
+ export const ${id.replaceAll('.', '_')} = trail('${id}', {
33
+ description: 'TODO: describe this trail',
34
+ examples: [
35
+ {
36
+ input: {},
37
+ name: 'TODO: add example',
38
+ },
39
+ ],
40
+ implementation: async (input) => {
41
+ return Result.ok({ message: 'TODO' });
42
+ },
43
+ input: z.object({}),${markerBlock}
44
+ output: z.object({ message: z.string() }),
45
+ });
46
+ `;
47
+ };
48
+
49
+ const generateTestFile = (id: string): string => {
50
+ const moduleName = id.replaceAll('.', '-');
51
+ const trailName = id.replaceAll('.', '_');
52
+ return `import { testTrail } from '@ontrails/testing';
53
+ import { ${trailName} } from '../src/trails/${moduleName}.js';
54
+
55
+ testTrail(${trailName}, [
56
+ { description: 'basic test', input: {}, expectOk: true },
57
+ ]);
58
+ `;
59
+ };
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Trail definition
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** Write a file, creating parent directories as needed. */
66
+ const writeWithDirs = async (
67
+ filePath: string,
68
+ content: string
69
+ ): Promise<void> => {
70
+ mkdirSync(dirname(filePath), { recursive: true });
71
+ await Bun.write(filePath, content);
72
+ };
73
+
74
+ export const addTrail = trail('add.trail', {
75
+ description: 'Scaffold a new trail with tests and examples',
76
+ implementation: async (input, ctx) => {
77
+ const { id } = input;
78
+ const moduleName = id.replaceAll('.', '-');
79
+ const cwd = resolve(ctx.cwd ?? '.');
80
+
81
+ const files = new Map<string, string>([
82
+ [
83
+ `src/trails/${moduleName}.ts`,
84
+ generateTrailFile(id, input.readOnly, input.destructive),
85
+ ],
86
+ [`__tests__/${moduleName}.test.ts`, generateTestFile(id)],
87
+ ]);
88
+
89
+ for (const [relativePath, content] of files) {
90
+ await writeWithDirs(join(cwd, relativePath), content);
91
+ }
92
+
93
+ return Result.ok({ created: [...files.keys()] });
94
+ },
95
+ input: z.object({
96
+ destructive: z.boolean().default(false).describe('Destructive trail'),
97
+ id: z.string().describe('Trail ID (e.g., entity.update)'),
98
+ readOnly: z.boolean().default(false).describe('Read-only trail'),
99
+ }),
100
+ output: z.object({
101
+ created: z.array(z.string()),
102
+ }),
103
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `add.verify` trail -- Add testing + warden setup to a project.
3
+ */
4
+
5
+ import { existsSync, mkdirSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+
8
+ import { Result, trail } from '@ontrails/core';
9
+ import { z } from 'zod';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Content generators
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const generateTestFile = (): string =>
16
+ `import { testAll } from '@ontrails/testing';
17
+ import { app } from '../src/app.js';
18
+
19
+ testAll(app);
20
+ `;
21
+
22
+ const generateLefthookYml = (): string =>
23
+ `pre-push:
24
+ commands:
25
+ warden:
26
+ run: bunx trails warden --exit-code
27
+ `;
28
+
29
+ /** Add testing and warden devDependencies to package.json when present. */
30
+ const patchVerifyDeps = (pkg: Record<string, unknown>): void => {
31
+ const devDeps = (pkg['devDependencies'] ?? {}) as Record<string, string>;
32
+ devDeps['@ontrails/testing'] = 'workspace:*';
33
+ devDeps['@ontrails/warden'] = 'workspace:*';
34
+ devDeps['lefthook'] = '^2.1.1';
35
+ pkg['devDependencies'] = Object.fromEntries(
36
+ Object.entries(devDeps).toSorted(([a], [b]) => a.localeCompare(b))
37
+ );
38
+ };
39
+
40
+ /** Update package.json in the target project with verify dependencies. */
41
+ const updatePackageJsonForVerify = async (
42
+ projectDir: string
43
+ ): Promise<void> => {
44
+ const pkgPath = join(projectDir, 'package.json');
45
+ if (!existsSync(pkgPath)) {
46
+ return;
47
+ }
48
+ const pkg = (await Bun.file(pkgPath).json()) as Record<string, unknown>;
49
+ patchVerifyDeps(pkg);
50
+ await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
51
+ };
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Trail definition
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export const addVerify = trail('add.verify', {
58
+ description: 'Add testing and warden verification',
59
+ implementation: async (input) => {
60
+ const projectDir = resolve(input.dir ?? '.', input.name);
61
+ const files: string[] = [];
62
+
63
+ const writeFile = async (
64
+ relativePath: string,
65
+ content: string
66
+ ): Promise<void> => {
67
+ const fullPath = join(projectDir, relativePath);
68
+ mkdirSync(dirname(fullPath), { recursive: true });
69
+ await Bun.write(fullPath, content);
70
+ files.push(relativePath);
71
+ };
72
+
73
+ await writeFile('__tests__/examples.test.ts', generateTestFile());
74
+ await writeFile('lefthook.yml', generateLefthookYml());
75
+ await updatePackageJsonForVerify(projectDir);
76
+
77
+ return Result.ok({ created: files });
78
+ },
79
+ input: z.object({
80
+ dir: z.string().optional().describe('Parent directory'),
81
+ name: z.string().describe('Project name'),
82
+ }),
83
+ markers: { internal: true },
84
+ output: z.object({
85
+ created: z.array(z.string()),
86
+ }),
87
+ });