@ultimat3/cli 27.6.1 → 27.7.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/CLAUDE.md +1 -1
- package/package.json +28 -28
- package/src/admin-registration.ts +15 -14
- package/src/api-registration.ts +48 -47
- package/src/cmd-generate.ts +65 -49
- package/src/dev-boot.ts +8 -1
- package/src/generate-disk.ts +38 -0
- package/src/generate-files.ts +6 -1
- package/src/generate-follow-ups.ts +64 -0
- package/src/generate-grant-findings.ts +8 -6
- package/src/generate-grants.ts +79 -52
- package/src/generate-kinds.ts +7 -2
- package/src/generate-plural.ts +11 -0
- package/src/generate-sibling.ts +34 -0
- package/src/generated-imports.ts +9 -6
- package/src/handle-registration.ts +71 -29
- package/src/i18n-index.ts +30 -14
- package/src/mcp-host.ts +2 -1
- package/src/runtime-bus.ts +158 -0
- package/src/runtime-cache.ts +141 -9
- package/src/runtime-services.ts +25 -37
- package/src/serve-boot.ts +4 -1
- package/src/serve-types.ts +6 -0
- package/src/serve.ts +8 -1
- package/src/source-list-edit.ts +218 -0
- package/src/templates/entity-module.ts +29 -0
- package/src/templates/entity.ts +30 -14
- package/src/workspace-dep-edit.ts +13 -10
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Adding an entry to an array literal in a file the app owns, read off the MASKED source. Every
|
|
2
|
+
// generator that grows a list — a role's `grants`, `definePermissions([…])`, a `defineApi` list —
|
|
3
|
+
// found its `]` with `indexOf` and its entries with a quote regex, so an apostrophe in a comment
|
|
4
|
+
// was a string delimiter and a `]` in one closed the list: `x g entity` rewrote a role map's
|
|
5
|
+
// comments into permissions. Here a comment and a literal's contents are never read as code, and
|
|
6
|
+
// an edit that does not read back as "the same list plus the new entries" is refused, not written.
|
|
7
|
+
|
|
8
|
+
import { maskLiterals, stripComments } from '@ultimat3/core';
|
|
9
|
+
import { wrapList } from './templates/wrap';
|
|
10
|
+
|
|
11
|
+
const OPEN = new Set(['(', '[', '{']);
|
|
12
|
+
const CLOSE = new Set([')', ']', '}']);
|
|
13
|
+
|
|
14
|
+
/** One top-level entry of a bracketed list: where its code starts and ends, and its exact text. */
|
|
15
|
+
export interface ListEntry {
|
|
16
|
+
readonly start: number;
|
|
17
|
+
readonly end: number;
|
|
18
|
+
readonly text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A bracketed list as written: the bracket pair and every top-level entry between them. */
|
|
22
|
+
export interface SourceList {
|
|
23
|
+
/** Index of the opening bracket. */
|
|
24
|
+
readonly open: number;
|
|
25
|
+
/** Index of the bracket that closes it. */
|
|
26
|
+
readonly close: number;
|
|
27
|
+
readonly entries: readonly ListEntry[];
|
|
28
|
+
/** Whether a comma follows the last entry. */
|
|
29
|
+
readonly trailingComma: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `maskLiterals(source)`, spelled here so every caller of this module reads one mask. */
|
|
33
|
+
export const maskOf = (source: string): string => maskLiterals(source);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Index of the bracket closing the one at `open`, or -1 when nothing closes it. `masked` is
|
|
37
|
+
* `maskOf(source)`: a bracket in a comment, a string or a regex is a space there.
|
|
38
|
+
*/
|
|
39
|
+
export function closingBracket(masked: string, open: number): number {
|
|
40
|
+
let depth = 0;
|
|
41
|
+
for (let index = open; index < masked.length; index += 1) {
|
|
42
|
+
const ch = masked[index] as string;
|
|
43
|
+
if (OPEN.has(ch)) depth += 1;
|
|
44
|
+
else if (CLOSE.has(ch)) {
|
|
45
|
+
depth -= 1;
|
|
46
|
+
if (depth === 0) return index;
|
|
47
|
+
if (depth < 0) return -1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return -1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The list whose opening bracket is at `open` — an array's `[` or an object's `{` — split at its
|
|
55
|
+
* own commas. `undefined` when the bracket never closes. An entry's span is its CODE: a comment
|
|
56
|
+
* above or beside it belongs to nobody, which is what lets an edit leave every comment alone.
|
|
57
|
+
*/
|
|
58
|
+
export function readList(source: string, masked: string, open: number): SourceList | undefined {
|
|
59
|
+
const close = closingBracket(masked, open);
|
|
60
|
+
if (close === -1) return undefined;
|
|
61
|
+
const entries: ListEntry[] = [];
|
|
62
|
+
let trailingComma = false;
|
|
63
|
+
const push = (from: number, to: number, comma: boolean): void => {
|
|
64
|
+
let start = from;
|
|
65
|
+
let end = to;
|
|
66
|
+
while (start < end && /\s/.test(masked[start] as string)) start += 1;
|
|
67
|
+
while (end > start && /\s/.test(masked[end - 1] as string)) end -= 1;
|
|
68
|
+
if (start < end) {
|
|
69
|
+
entries.push({ start, end, text: source.slice(start, end) });
|
|
70
|
+
trailingComma = comma;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
let depth = 0;
|
|
74
|
+
let from = open + 1;
|
|
75
|
+
for (let index = open + 1; index < close; index += 1) {
|
|
76
|
+
const ch = masked[index] as string;
|
|
77
|
+
if (OPEN.has(ch)) depth += 1;
|
|
78
|
+
else if (CLOSE.has(ch)) depth -= 1;
|
|
79
|
+
else if (ch === ',' && depth === 0) {
|
|
80
|
+
push(from, index, true);
|
|
81
|
+
from = index + 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
push(from, close, false);
|
|
85
|
+
return { open, close, entries, trailingComma };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What a plain string literal holds, or `undefined` for any other expression. */
|
|
89
|
+
const QUOTED = /^(['"`])((?:(?!\1)[^\\$\n])*)\1$/;
|
|
90
|
+
export const literalValue = (text: string): string | undefined => QUOTED.exec(text)?.[2];
|
|
91
|
+
|
|
92
|
+
/** Two entries are the same when their text is, or when both are literals of one value. */
|
|
93
|
+
const sameEntry = (left: string, right: string): boolean =>
|
|
94
|
+
left === right ||
|
|
95
|
+
(literalValue(left) !== undefined && literalValue(left) === literalValue(right));
|
|
96
|
+
|
|
97
|
+
/** The entries of `wanted` the list does not hold yet, each once. */
|
|
98
|
+
export function absentFrom(list: SourceList, wanted: readonly string[]): readonly string[] {
|
|
99
|
+
const absent: string[] = [];
|
|
100
|
+
for (const entry of wanted) {
|
|
101
|
+
const held = [...list.entries.map((one) => one.text), ...absent];
|
|
102
|
+
if (!held.some((text) => sameEntry(text, entry))) absent.push(entry);
|
|
103
|
+
}
|
|
104
|
+
return absent;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const leadingSpace = (text: string): string => /^[ \t]*/.exec(text)?.[0] ?? '';
|
|
108
|
+
|
|
109
|
+
/** A list that holds no comment: re-wrapped whole, on one line when it fits and a row each when not. */
|
|
110
|
+
function rewrapped(source: string, list: SourceList, entries: readonly string[]): string {
|
|
111
|
+
const lineStart = source.lastIndexOf('\n', list.open) + 1;
|
|
112
|
+
const newline = source.indexOf('\n', list.close);
|
|
113
|
+
const lineEnd = newline === -1 ? source.length : newline;
|
|
114
|
+
const head = source.slice(lineStart, list.open + 1);
|
|
115
|
+
const indent = leadingSpace(head);
|
|
116
|
+
const all = [...list.entries.map((entry) => entry.text), ...entries];
|
|
117
|
+
const line = wrapList(indent, head.slice(indent.length), all, source.slice(list.close, lineEnd));
|
|
118
|
+
return `${source.slice(0, lineStart)}${line}${source.slice(lineEnd)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A list over several lines: the new entries are INSERTED, and no existing byte moves. */
|
|
122
|
+
function inserted(source: string, list: SourceList, entries: readonly string[]): string {
|
|
123
|
+
const closeLine = source.lastIndexOf('\n', list.close) + 1;
|
|
124
|
+
const beforeClose = source.slice(closeLine, list.close);
|
|
125
|
+
const last = list.entries.at(-1);
|
|
126
|
+
if (beforeClose.trim() !== '') {
|
|
127
|
+
// The closing bracket shares its line with code: the entries go right before it.
|
|
128
|
+
const lead = last === undefined ? '' : list.trailingComma ? ' ' : ', ';
|
|
129
|
+
return `${source.slice(0, list.close)}${lead}${entries.join(', ')}${source.slice(list.close)}`;
|
|
130
|
+
}
|
|
131
|
+
// The comma the last entry owes, placed right after its code — before any comment beside it.
|
|
132
|
+
const comma = last !== undefined && !list.trailingComma ? last.end : undefined;
|
|
133
|
+
const before = source.slice(0, closeLine);
|
|
134
|
+
const head = comma === undefined ? before : `${before.slice(0, comma)},${before.slice(comma)}`;
|
|
135
|
+
const first = list.entries[0];
|
|
136
|
+
const firstLine = first === undefined ? -1 : source.lastIndexOf('\n', first.start) + 1;
|
|
137
|
+
// An entry's own indent where one starts its line; else one step in from the closing bracket.
|
|
138
|
+
const indent =
|
|
139
|
+
first !== undefined && source.slice(firstLine, first.start).trim() === ''
|
|
140
|
+
? source.slice(firstLine, first.start)
|
|
141
|
+
: `${beforeClose} `;
|
|
142
|
+
const rows = entries.map((entry) => `${indent}${entry},\n`).join('');
|
|
143
|
+
return `${head}${rows}${source.slice(closeLine)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `source` with `wanted` added to the list opening at `open`, or `undefined` when that cannot be
|
|
148
|
+
* done safely — the caller then reports the edit instead of making it.
|
|
149
|
+
*
|
|
150
|
+
* A list that holds NO comment is re-wrapped the way Biome prints it — on one line while it fits,
|
|
151
|
+
* a row per entry once it does not — because a formatter would move it there anyway and the app's
|
|
152
|
+
* own `lint` step reads the difference. A list with a comment in it, or with an entry that spans
|
|
153
|
+
* rows, gains rows before its closing bracket and nothing else changes: every comment, every
|
|
154
|
+
* literal and every byte outside the insertion is the author's.
|
|
155
|
+
*
|
|
156
|
+
* The result is READ BACK before it is answered: the list must hold exactly what it held plus the
|
|
157
|
+
* new entries, and the text before and after it must be untouched. A shape this reasoning is wrong
|
|
158
|
+
* about fails that check here, never in the app.
|
|
159
|
+
*/
|
|
160
|
+
export function appendToList(
|
|
161
|
+
source: string,
|
|
162
|
+
open: number,
|
|
163
|
+
wanted: readonly string[],
|
|
164
|
+
): string | undefined {
|
|
165
|
+
const masked = maskOf(source);
|
|
166
|
+
const list = readList(source, masked, open);
|
|
167
|
+
if (list === undefined) return undefined;
|
|
168
|
+
const entries = absentFrom(list, wanted);
|
|
169
|
+
if (entries.length === 0) return source;
|
|
170
|
+
const span = source.slice(list.open, list.close + 1);
|
|
171
|
+
const commented = stripComments(source).slice(list.open, list.close + 1) !== span;
|
|
172
|
+
const simple = list.entries.every((entry) => !entry.text.includes('\n'));
|
|
173
|
+
const next =
|
|
174
|
+
!commented && simple ? rewrapped(source, list, entries) : inserted(source, list, entries);
|
|
175
|
+
// Read back: same prefix, same suffix, the old entries then the new ones.
|
|
176
|
+
const reread = readList(next, maskOf(next), open);
|
|
177
|
+
const expected = [...list.entries.map((entry) => entry.text), ...entries];
|
|
178
|
+
if (
|
|
179
|
+
reread === undefined ||
|
|
180
|
+
next.slice(0, open) !== source.slice(0, open) ||
|
|
181
|
+
next.slice(reread.close) !== source.slice(list.close) ||
|
|
182
|
+
reread.entries.length !== expected.length ||
|
|
183
|
+
reread.entries.some((entry, index) => entry.text !== expected[index])
|
|
184
|
+
) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return next;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One `key: value` of an object literal, located on the masked text. */
|
|
191
|
+
export interface SourceProperty {
|
|
192
|
+
readonly key: string;
|
|
193
|
+
/** Index of the first character of the value. */
|
|
194
|
+
readonly value: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The `key: value` properties of the object opening at `open`. A shorthand, a spread, a method
|
|
199
|
+
* and a computed key are not `key: value` and are not answered.
|
|
200
|
+
*/
|
|
201
|
+
export function readProperties(
|
|
202
|
+
source: string,
|
|
203
|
+
masked: string,
|
|
204
|
+
open: number,
|
|
205
|
+
): readonly SourceProperty[] {
|
|
206
|
+
const list = readList(source, masked, open);
|
|
207
|
+
if (list === undefined) return [];
|
|
208
|
+
return list.entries.flatMap((entry) => {
|
|
209
|
+
const colon = /^(?:[A-Za-z_$][\w$]*|'[^']*'|"[^"]*")\s*:/.exec(
|
|
210
|
+
masked.slice(entry.start, entry.end),
|
|
211
|
+
);
|
|
212
|
+
if (colon === null) return [];
|
|
213
|
+
const raw = source.slice(entry.start, entry.start + colon[0].length - 1).trim();
|
|
214
|
+
let value = entry.start + colon[0].length;
|
|
215
|
+
while (value < entry.end && /\s/.test(masked[value] as string)) value += 1;
|
|
216
|
+
return [{ key: literalValue(raw) ?? raw, value }];
|
|
217
|
+
});
|
|
218
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Which files of a slice declare an entity. The first is `entity.ts`; every further one is a
|
|
2
|
+
// sibling, `entity-<name>.ts`, with its own `repo-<name>.ts` — one file, one table. Every reader of
|
|
3
|
+
// "the entity files this run wrote" asks here: three of them matched `/entity.ts` alone, so a
|
|
4
|
+
// second entity in a feature could be written by nothing and registered by nothing.
|
|
5
|
+
|
|
6
|
+
/** `apps/<app>/<surface>/<feature>/entity.ts` or `…/entity-<name>.ts` — never a test beside it. */
|
|
7
|
+
const ENTITY_MODULE =
|
|
8
|
+
/^apps\/[^/]+\/(?<rest>[^/]+\/[a-z0-9-]+)\/(?<stem>entity(?:-[a-z0-9-]+)?)\.ts$/;
|
|
9
|
+
|
|
10
|
+
/** Whether `path` is a module a generator declares an entity in. */
|
|
11
|
+
export const isEntityModule = (path: string): boolean => ENTITY_MODULE.test(path);
|
|
12
|
+
|
|
13
|
+
/** `app/blog/entity-blog-attempt` for a web-workspace entity module; `undefined` for any other. */
|
|
14
|
+
export const entityModuleIn = (path: string, workspace: string): string | undefined => {
|
|
15
|
+
if (!path.startsWith(`${workspace}/`)) return undefined;
|
|
16
|
+
const groups = ENTITY_MODULE.exec(path)?.groups;
|
|
17
|
+
return groups === undefined ? undefined : `${groups['rest']}/${groups['stem']}`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The two module names an entity's files take: the slice's first, or a named sibling's. */
|
|
21
|
+
export interface EntityStems {
|
|
22
|
+
readonly entity: string;
|
|
23
|
+
readonly repo: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const entityStems = (kebabName: string, sibling: boolean): EntityStems =>
|
|
27
|
+
sibling
|
|
28
|
+
? { entity: `entity-${kebabName}`, repo: `repo-${kebabName}` }
|
|
29
|
+
: { entity: 'entity', repo: 'repo' };
|
package/src/templates/entity.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// rather than copied fixture files so the generator output is typed, diffable and testable from a
|
|
4
4
|
// unit test.
|
|
5
5
|
|
|
6
|
+
import type { EntityStems } from './entity-module';
|
|
7
|
+
import { entityStems } from './entity-module';
|
|
6
8
|
import { sortedImports } from './imports';
|
|
7
9
|
import type { GeneratedFile, NameSet } from './naming';
|
|
8
10
|
import { names } from './naming';
|
|
@@ -28,8 +30,9 @@ const entitySource = (
|
|
|
28
30
|
name: NameSet,
|
|
29
31
|
snake: string,
|
|
30
32
|
table: string,
|
|
33
|
+
stems: EntityStems,
|
|
31
34
|
): string => `// The ${name.camel} table, its domain type and its invariants. No I/O beyond the column
|
|
32
|
-
// definitions: repo.ts owns every query that touches this table.
|
|
35
|
+
// definitions: ${stems.repo}.ts owns every query that touches this table.
|
|
33
36
|
|
|
34
37
|
import { entity, invariant, money, text, timestamp, uuid } from '@ultimat3/entity';
|
|
35
38
|
|
|
@@ -39,9 +42,9 @@ export const ${name.camel} = entity('${table}', {
|
|
|
39
42
|
//
|
|
40
43
|
// SINGLE-TENANT APP? There is no flag: \`x g action\`, \`x g query\` and \`x g policy\` decide on
|
|
41
44
|
// \`orgId\` too, so the edit is per slice and it is this: delete \`tenant: 'orgId'\` and the
|
|
42
|
-
// \`orgId: uuid()\` column below and drop \`'orgId'\` from \`indexes\`. repo.ts needs no edit —
|
|
43
|
-
// it names no org. entity.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false,
|
|
44
|
-
// its \`row()\` fixture and repo.test.ts's \`draft()\` lose \`orgId\`, and repo.test.ts loses its
|
|
45
|
+
// \`orgId: uuid()\` column below and drop \`'orgId'\` from \`indexes\`. ${stems.repo}.ts needs no edit —
|
|
46
|
+
// it names no org. ${stems.entity}.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false,
|
|
47
|
+
// its \`row()\` fixture and ${stems.repo}.test.ts's \`draft()\` lose \`orgId\`, and ${stems.repo}.test.ts loses its
|
|
45
48
|
// last test. Nothing else reads the column.
|
|
46
49
|
tenant: 'orgId',
|
|
47
50
|
columns: {
|
|
@@ -84,7 +87,7 @@ const chain = (indent: string, head: string, calls: readonly string[]): string =
|
|
|
84
87
|
return `${indent}${head}\n${calls.map((call) => `${indent} ${call}`).join('\n')};`;
|
|
85
88
|
};
|
|
86
89
|
|
|
87
|
-
const repoSource = (name: NameSet, dbModule: string): string => {
|
|
90
|
+
const repoSource = (name: NameSet, dbModule: string, stems: EntityStems): string => {
|
|
88
91
|
const row = name.pascal;
|
|
89
92
|
const table = `db.${name.plural}`;
|
|
90
93
|
const listSignature = wrapList(
|
|
@@ -124,7 +127,7 @@ const repoSource = (name: NameSet, dbModule: string): string => {
|
|
|
124
127
|
// it here only for a statement the handle cannot express — a join, a window, a CTE.
|
|
125
128
|
|
|
126
129
|
import { db } from '${dbModule}';
|
|
127
|
-
import type { ${row} } from '
|
|
130
|
+
import type { ${row} } from './${stems.entity}';
|
|
128
131
|
|
|
129
132
|
${byIdSignature}
|
|
130
133
|
${byIdBody}
|
|
@@ -153,6 +156,7 @@ ${insertSignature}
|
|
|
153
156
|
const repoTest = (
|
|
154
157
|
name: NameSet,
|
|
155
158
|
dbModule: string,
|
|
159
|
+
stems: EntityStems,
|
|
156
160
|
): string => `// The ${name.kebab} repo against the in-memory driver: the contract Postgres serves, with no
|
|
157
161
|
// database. What it pins is what the repo cannot show by being read — whose rows a call reaches.
|
|
158
162
|
${sortedImports([
|
|
@@ -161,7 +165,7 @@ ${sortedImports([
|
|
|
161
165
|
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
162
166
|
`import { driver } from '${dbModule}';`,
|
|
163
167
|
])}
|
|
164
|
-
import * as repo from '
|
|
168
|
+
import * as repo from './${stems.repo}';
|
|
165
169
|
|
|
166
170
|
const orgId = '00000000-0000-4000-8000-000000000002';
|
|
167
171
|
const otherOrg = '00000000-0000-4000-8000-000000000009';
|
|
@@ -223,11 +227,12 @@ const entityTest = (
|
|
|
223
227
|
name: NameSet,
|
|
224
228
|
snake: string,
|
|
225
229
|
table: string,
|
|
230
|
+
stems: EntityStems,
|
|
226
231
|
): string => `// The ${name.kebab} entity's declaration: the table it maps to and the invariants it names. Both
|
|
227
232
|
// are what the migration generator and every query read, so both are worth pinning.
|
|
228
233
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
229
|
-
import type { ${name.pascal} } from '
|
|
230
|
-
${wrapImport([`${name.pascal}View`, name.camel],
|
|
234
|
+
import type { ${name.pascal} } from './${stems.entity}';
|
|
235
|
+
${wrapImport([`${name.pascal}View`, name.camel], `./${stems.entity}`)}
|
|
231
236
|
|
|
232
237
|
type Over = Partial<${name.pascal}>;
|
|
233
238
|
|
|
@@ -293,14 +298,25 @@ unitTest('${name.camel} parses a row through its own columns', () => {
|
|
|
293
298
|
});
|
|
294
299
|
`;
|
|
295
300
|
|
|
296
|
-
|
|
301
|
+
/**
|
|
302
|
+
* `sibling` is the feature's SECOND entity onward: `entity.ts` is already another table's, so this
|
|
303
|
+
* one lands beside it as `entity-<name>.ts` with its own `repo-<name>.ts` — a file per table, and
|
|
304
|
+
* nothing the author wrote is edited to make room.
|
|
305
|
+
*/
|
|
306
|
+
export function entityFiles(
|
|
307
|
+
rawName: string,
|
|
308
|
+
target: FeatureTarget,
|
|
309
|
+
options: { readonly sibling?: boolean } = {},
|
|
310
|
+
): readonly GeneratedFile[] {
|
|
297
311
|
const name = names(rawName);
|
|
298
312
|
const dir = `${target.surfaceDir}/${target.feature}`;
|
|
299
313
|
const dbModule = target.dbModule ?? PLACEHOLDER_DB_MODULE;
|
|
314
|
+
const stems = entityStems(name.kebab, options.sibling === true);
|
|
315
|
+
const { snake, table } = name;
|
|
300
316
|
return [
|
|
301
|
-
{ path: `${dir}
|
|
302
|
-
{ path: `${dir}
|
|
303
|
-
{ path: `${dir}
|
|
304
|
-
{ path: `${dir}
|
|
317
|
+
{ path: `${dir}/${stems.entity}.ts`, contents: entitySource(name, snake, table, stems) },
|
|
318
|
+
{ path: `${dir}/${stems.entity}.test.ts`, contents: entityTest(name, snake, table, stems) },
|
|
319
|
+
{ path: `${dir}/${stems.repo}.ts`, contents: repoSource(name, dbModule, stems) },
|
|
320
|
+
{ path: `${dir}/${stems.repo}.test.ts`, contents: repoTest(name, dbModule, stems) },
|
|
305
321
|
];
|
|
306
322
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// (`X_WORKSPACE_DEP_UNDECLARED`), and re-serialising the whole file would reformat arrays and
|
|
4
4
|
// spacing the author — or their formatter — chose.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { GenerateDisk } from './generate-disk';
|
|
7
|
+
import { appDisk } from './generate-disk';
|
|
7
8
|
|
|
8
9
|
const MANIFEST = 'package.json';
|
|
9
10
|
|
|
@@ -76,10 +77,10 @@ export function withDependency(
|
|
|
76
77
|
export async function readWorkspace(
|
|
77
78
|
root: string,
|
|
78
79
|
dir: string,
|
|
80
|
+
disk: GenerateDisk = appDisk(root),
|
|
79
81
|
): Promise<{ readonly name: string; readonly version: string } | undefined> {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
const parsed = parseManifest(await file.text());
|
|
82
|
+
const text = await disk.read(`${dir}/${MANIFEST}`);
|
|
83
|
+
const parsed = text === undefined ? undefined : parseManifest(text);
|
|
83
84
|
if (parsed === undefined || typeof parsed.name !== 'string' || parsed.name === '') {
|
|
84
85
|
return undefined;
|
|
85
86
|
}
|
|
@@ -105,12 +106,13 @@ export async function planWorkspaceDependency(
|
|
|
105
106
|
root: string,
|
|
106
107
|
fromDir: string,
|
|
107
108
|
toDir: string,
|
|
109
|
+
disk: GenerateDisk = appDisk(root),
|
|
108
110
|
): Promise<PlannedEdit | undefined> {
|
|
109
|
-
const target = await readWorkspace(root, toDir);
|
|
111
|
+
const target = await readWorkspace(root, toDir, disk);
|
|
110
112
|
const path = `${fromDir}/${MANIFEST}`;
|
|
111
|
-
const
|
|
112
|
-
if (target === undefined ||
|
|
113
|
-
const contents = withDependency(
|
|
113
|
+
const text = await disk.read(path);
|
|
114
|
+
if (target === undefined || text === undefined) return undefined;
|
|
115
|
+
const contents = withDependency(text, target.name, target.version);
|
|
114
116
|
return contents === undefined ? undefined : { path, contents };
|
|
115
117
|
}
|
|
116
118
|
|
|
@@ -119,9 +121,10 @@ export async function declareWorkspaceDependency(
|
|
|
119
121
|
root: string,
|
|
120
122
|
fromDir: string,
|
|
121
123
|
toDir: string,
|
|
124
|
+
disk: GenerateDisk = appDisk(root),
|
|
122
125
|
): Promise<string | undefined> {
|
|
123
|
-
const edit = await planWorkspaceDependency(root, fromDir, toDir);
|
|
126
|
+
const edit = await planWorkspaceDependency(root, fromDir, toDir, disk);
|
|
124
127
|
if (edit === undefined) return undefined;
|
|
125
|
-
await
|
|
128
|
+
await disk.write(edit.path, edit.contents);
|
|
126
129
|
return edit.path;
|
|
127
130
|
}
|