@basaltkit/ai 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context/project.d.ts +21 -0
- package/dist/context/project.js +85 -2
- package/dist/doctor/rules.js +24 -0
- package/dist/index.d.ts +1 -1
- package/dist/make/schema.d.ts +5 -1
- package/dist/make/schema.js +6 -2
- package/package.json +6 -6
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
export interface ProjectReader {
|
|
6
6
|
read(relPath: string): string | null;
|
|
7
7
|
exists(relPath: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Every file under `relDir`, recursively, as paths relative to the root.
|
|
10
|
+
* Optional: a reader without it simply skips the checks that scan the
|
|
11
|
+
* project's route files.
|
|
12
|
+
*/
|
|
13
|
+
list?(relDir: string): string[];
|
|
8
14
|
}
|
|
9
15
|
/** Default reader backed by the filesystem. */
|
|
10
16
|
export declare function nodeReader(root: string): ProjectReader;
|
|
@@ -64,8 +70,23 @@ export interface EnvFileInfo {
|
|
|
64
70
|
/** Default value baked into `REDIS_URL`, if any. */
|
|
65
71
|
redisUrlDefault: string | null;
|
|
66
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* A route declared central (`meta.tenant: false`) whose permission names the
|
|
75
|
+
* platform plane (`can: 'platform:…'`), without `tenant: 'never'`. It still
|
|
76
|
+
* runs on a tenant host, against that tenant's storage.
|
|
77
|
+
*/
|
|
78
|
+
export interface CentralPlatformRouteHint {
|
|
79
|
+
path: string;
|
|
80
|
+
/** 1-based line of the `meta` object. */
|
|
81
|
+
line: number;
|
|
82
|
+
}
|
|
67
83
|
export interface ProjectContext {
|
|
68
84
|
root: string;
|
|
85
|
+
/**
|
|
86
|
+
* Platform routes that do not refuse a tenant. Absent when the reader cannot
|
|
87
|
+
* list files.
|
|
88
|
+
*/
|
|
89
|
+
centralPlatformRoutes?: CentralPlatformRouteHint[];
|
|
69
90
|
/** All `@basaltkit/*` packages found in package.json deps. */
|
|
70
91
|
installed: string[];
|
|
71
92
|
stack: DetectedStack;
|
package/dist/context/project.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
/** Upper bound on the files a route scan reads — a doctor run stays cheap on a large repo. */
|
|
4
|
+
const MAX_SCANNED_FILES = 2000;
|
|
3
5
|
/** Default reader backed by the filesystem. */
|
|
4
6
|
export function nodeReader(root) {
|
|
5
7
|
return {
|
|
@@ -12,13 +14,49 @@ export function nodeReader(root) {
|
|
|
12
14
|
}
|
|
13
15
|
},
|
|
14
16
|
exists: (relPath) => existsSync(join(root, relPath)),
|
|
17
|
+
list(relDir) {
|
|
18
|
+
const out = [];
|
|
19
|
+
const walk = (dir) => {
|
|
20
|
+
let entries;
|
|
21
|
+
try {
|
|
22
|
+
entries = readdirSync(join(root, dir), { withFileTypes: true });
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (out.length >= MAX_SCANNED_FILES)
|
|
29
|
+
return;
|
|
30
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.'))
|
|
31
|
+
continue;
|
|
32
|
+
const rel = `${dir}/${entry.name}`;
|
|
33
|
+
if (entry.isDirectory())
|
|
34
|
+
walk(rel);
|
|
35
|
+
else if (entry.isFile())
|
|
36
|
+
out.push(rel);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(trimTrailingSlashes(relDir));
|
|
40
|
+
return out;
|
|
41
|
+
},
|
|
15
42
|
};
|
|
16
43
|
}
|
|
44
|
+
/** Drops trailing `/` characters in linear time (a `/\/+$/` regex backtracks on long runs of `/`). */
|
|
45
|
+
function trimTrailingSlashes(path) {
|
|
46
|
+
let end = path.length;
|
|
47
|
+
while (end > 0 && path.charCodeAt(end - 1) === 47)
|
|
48
|
+
end--;
|
|
49
|
+
return path.slice(0, end);
|
|
50
|
+
}
|
|
17
51
|
/** In-memory reader for tests: keys are relative paths. */
|
|
18
52
|
export function memoryReader(files) {
|
|
19
53
|
return {
|
|
20
54
|
read: (relPath) => files[relPath] ?? null,
|
|
21
55
|
exists: (relPath) => relPath in files,
|
|
56
|
+
list: (relDir) => {
|
|
57
|
+
const prefix = `${trimTrailingSlashes(relDir)}/`;
|
|
58
|
+
return Object.keys(files).filter((path) => path.startsWith(prefix));
|
|
59
|
+
},
|
|
22
60
|
};
|
|
23
61
|
}
|
|
24
62
|
const APP_CANDIDATES = ['src/app.ts', 'src/app.js', 'app.ts'];
|
|
@@ -58,7 +96,52 @@ export function detectProject(root, reader = nodeReader(root)) {
|
|
|
58
96
|
const env = detectEnvFile(reader);
|
|
59
97
|
const prisma = detectPrisma(reader);
|
|
60
98
|
const stack = buildStack(installed, app, prisma);
|
|
61
|
-
|
|
99
|
+
const centralPlatformRoutes = detectCentralPlatformRoutes(reader);
|
|
100
|
+
return {
|
|
101
|
+
root,
|
|
102
|
+
installed,
|
|
103
|
+
stack,
|
|
104
|
+
prisma,
|
|
105
|
+
app,
|
|
106
|
+
server,
|
|
107
|
+
env,
|
|
108
|
+
...(centralPlatformRoutes ? { centralPlatformRoutes } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Finds `meta: { … }` literals with `tenant: false` and a `can` naming
|
|
113
|
+
* `platform:…`. A regex over flat object literals, not a parser: it sees
|
|
114
|
+
* `meta: { tenant: false, can: 'platform:plans' }` written inline, which is
|
|
115
|
+
* how routes are declared; meta built elsewhere and spread in goes unseen.
|
|
116
|
+
*/
|
|
117
|
+
function detectCentralPlatformRoutes(reader) {
|
|
118
|
+
if (!reader.list)
|
|
119
|
+
return undefined;
|
|
120
|
+
const files = reader
|
|
121
|
+
.list('src')
|
|
122
|
+
.filter((path) => /\.[cm]?[jt]s$/.test(path) && !/\.d\.ts$|\.(test|spec)\.[cm]?[jt]s$/.test(path))
|
|
123
|
+
.slice(0, MAX_SCANNED_FILES);
|
|
124
|
+
const hits = [];
|
|
125
|
+
for (const path of files) {
|
|
126
|
+
const content = reader.read(path);
|
|
127
|
+
if (!content || !content.includes('platform:'))
|
|
128
|
+
continue;
|
|
129
|
+
// Same length as the source, comments and string text blanked: a match
|
|
130
|
+
// must start in code, so a commented-out route never counts.
|
|
131
|
+
const code = codeOnly(content);
|
|
132
|
+
for (const match of content.matchAll(/\bmeta\s*:\s*\{([^{}]*)\}/g)) {
|
|
133
|
+
const index = match.index ?? 0;
|
|
134
|
+
if (code.slice(index, index + 4) !== 'meta')
|
|
135
|
+
continue;
|
|
136
|
+
const body = match[1] ?? '';
|
|
137
|
+
if (!/\btenant\s*:\s*false\b/.test(body))
|
|
138
|
+
continue;
|
|
139
|
+
if (!/\bcan\s*:\s*\[?\s*['"`]platform:/.test(body))
|
|
140
|
+
continue;
|
|
141
|
+
hits.push({ path, line: content.slice(0, index).split('\n').length });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return hits;
|
|
62
145
|
}
|
|
63
146
|
function detectInstalled(reader) {
|
|
64
147
|
const raw = reader.read('package.json');
|
package/dist/doctor/rules.js
CHANGED
|
@@ -135,6 +135,30 @@ export const DEFAULT_RULES = [
|
|
|
135
135
|
};
|
|
136
136
|
},
|
|
137
137
|
}),
|
|
138
|
+
defineRule({
|
|
139
|
+
id: 'platform-route-accepts-tenant',
|
|
140
|
+
category: 'tenancy',
|
|
141
|
+
check(ctx) {
|
|
142
|
+
const hits = ctx.centralPlatformRoutes ?? [];
|
|
143
|
+
if (hits.length === 0)
|
|
144
|
+
return null;
|
|
145
|
+
const where = hits.map((hit) => `${hit.path}:${hit.line}`).join(', ');
|
|
146
|
+
return {
|
|
147
|
+
id: 'platform-route-accepts-tenant',
|
|
148
|
+
title: "Platform route declares tenant: false instead of tenant: 'never'",
|
|
149
|
+
severity: 'warning',
|
|
150
|
+
category: 'tenancy',
|
|
151
|
+
detected: `platform routes still served on tenant hosts: ${where}`,
|
|
152
|
+
recommended: "declare meta: { tenant: 'never' } on routes of the central plane",
|
|
153
|
+
reason: '`tenant: false` lifts the tenant requirement but still resolves one: ' +
|
|
154
|
+
'acme.<host>/platform/… runs inside Acme, against its database, and a ' +
|
|
155
|
+
"tenant owner holding '*' satisfies 'platform:*'. `tenant: 'never'` answers " +
|
|
156
|
+
'a tenant host with the plain 404 before any guard runs.',
|
|
157
|
+
fix: "meta: { tenant: 'never', auth: true, can: 'platform:plans.manage' }",
|
|
158
|
+
docs: '/guide/multi-tenant-pattern',
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
138
162
|
defineRule({
|
|
139
163
|
id: 'tenant-scoping-missing',
|
|
140
164
|
category: 'tenancy',
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { fetchWithRetry, type RetryOptions } from './provider/http.js';
|
|
|
6
6
|
export { globalSseFetch, parseSseContent, type SseFetch, type SseResponse } from './provider/sse.js';
|
|
7
7
|
export { createProvider, providerEnvFrom, providerEnvFromProcess, type CreateProviderOptions, type ProviderEnv, type ProviderName, } from './provider/factory.js';
|
|
8
8
|
export { runGeneration, generateText, withAbort, throwIfAborted, abortError, isAbortError, type OnProgress, type WorkflowProgress, type WorkflowRunOptions, } from './generate.js';
|
|
9
|
-
export { detectProject, nodeReader, memoryReader, type AppFileInfo, type DetectedStack, type EnvFileInfo, type PrismaInfo, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, } from './context/project.js';
|
|
9
|
+
export { detectProject, nodeReader, memoryReader, type AppFileInfo, type CentralPlatformRouteHint, type DetectedStack, type EnvFileInfo, type PrismaInfo, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, } from './context/project.js';
|
|
10
10
|
export { defineRule, type Diagnostic, type DiagnosticCategory, type DoctorRule, type Severity, } from './doctor/types.js';
|
|
11
11
|
export { DEFAULT_RULES } from './doctor/rules.js';
|
|
12
12
|
export { hasErrors, runDoctor } from './doctor/run.js';
|
package/dist/make/schema.d.ts
CHANGED
|
@@ -18,5 +18,9 @@ export interface PrismaPushResult {
|
|
|
18
18
|
/** Tail of stdout/stderr. */
|
|
19
19
|
output: string;
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Run the project's own `prisma db push` — creates the table(s) and regenerates the client.
|
|
23
|
+
* Best-effort. `--no` keeps npx from downloading a Prisma CLI the project does not have
|
|
24
|
+
* (npm's `latest` is the Prisma 8 release candidate, which has no `db push`).
|
|
25
|
+
*/
|
|
22
26
|
export declare function runPrismaPush(baseDir: string): Promise<PrismaPushResult>;
|
package/dist/make/schema.js
CHANGED
|
@@ -36,10 +36,14 @@ export async function readSchema(baseDir, schemaPath) {
|
|
|
36
36
|
export async function writeSchema(baseDir, schemaPath, content) {
|
|
37
37
|
await writeFile(join(baseDir, schemaPath), content);
|
|
38
38
|
}
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Run the project's own `prisma db push` — creates the table(s) and regenerates the client.
|
|
41
|
+
* Best-effort. `--no` keeps npx from downloading a Prisma CLI the project does not have
|
|
42
|
+
* (npm's `latest` is the Prisma 8 release candidate, which has no `db push`).
|
|
43
|
+
*/
|
|
40
44
|
export async function runPrismaPush(baseDir) {
|
|
41
45
|
try {
|
|
42
|
-
const { stdout } = await run('npx', ['prisma', 'db', 'push'], {
|
|
46
|
+
const { stdout } = await run('npx', ['--no', '--', 'prisma', 'db', 'push'], {
|
|
43
47
|
cwd: baseDir,
|
|
44
48
|
timeout: 180_000,
|
|
45
49
|
maxBuffer: 10 * 1024 * 1024,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22.5.0"
|
|
6
6
|
},
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
"dist"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"zod": "^4.
|
|
34
|
-
"@basaltkit/cli": "^1.2
|
|
35
|
-
"@basaltkit/generator": "^1.
|
|
33
|
+
"zod": "^4.6.7",
|
|
34
|
+
"@basaltkit/cli": "^1.4.2",
|
|
35
|
+
"@basaltkit/generator": "^1.7.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@types/node": "^26.5
|
|
38
|
+
"@types/node": "^26.6.5",
|
|
39
39
|
"typescript": "^7.0.2",
|
|
40
|
-
"vitest": "^5.0.
|
|
40
|
+
"vitest": "^5.0.5",
|
|
41
41
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
42
42
|
},
|
|
43
43
|
"publishConfig": {
|