rootr-cli 0.18.0 → 0.19.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/README.ko.md +49 -2
- package/README.md +46 -4
- package/bin/rootr.js +481 -11
- package/lib/client.js +183 -24
- package/lib/credentials.js +20 -2
- package/lib/mcp-tools/ask.js +6 -1
- package/lib/mcp-tools/attachments.js +31 -11
- package/lib/mcp-tools/booking.js +403 -0
- package/lib/mcp-tools/databases.js +230 -9
- package/lib/mcp-tools/documents.js +183 -16
- package/lib/mcp-tools/forms.js +3 -1
- package/lib/mcp-tools/logs.js +1 -1
- package/lib/mcp-tools/misc.js +9 -3
- package/lib/mcp-tools/pages.js +31 -7
- package/lib/mcp-tools/presentations.js +36 -12
- package/lib/mcp-tools/public.js +4 -1
- package/lib/mcp-tools/recordings.js +39 -15
- package/lib/mcp-tools/shared.js +28 -0
- package/lib/mcp-tools/workspaces.js +155 -77
- package/lib/mcp.js +3 -0
- package/lib/resolve.js +43 -1
- package/package.json +1 -1
|
@@ -19,7 +19,12 @@ const ACCESS_HINT =
|
|
|
19
19
|
'`access` can see it and everything under it) plus `access: [{ subjectId: "<userId>", level: "READ"|"WRITE"|' +
|
|
20
20
|
'"MANAGE" }]` — that pair is how "make a folder these three people can see" happens in one call instead of ' +
|
|
21
21
|
'leaving the locking to the user afterwards. A DATABASE node may also carry `database.permissions` (칸/행 ' +
|
|
22
|
-
'lock) and `database.notifications` (who to notify on row changes).'
|
|
22
|
+
'lock) and `database.notifications` (who to notify on row changes). A database property may carry ' +
|
|
23
|
+
'`searchExcluded: true` (values stay out of the search index behind ask / AI explore / similar rows — not a read permission) and a `type: "vector"` ' +
|
|
24
|
+
'property needs `dimensions` (an embedding column agents fill with rootr_set_row_vectors). ' +
|
|
25
|
+
'A DOCUMENT\'s `content` may embed a live ' +
|
|
26
|
+
'table/sheet/deck with a line `[[embed:<name of a node in this same tree>]]` or `[[embed:/absolute/path]]` — it is ' +
|
|
27
|
+
'linked to the real node after the whole tree is built (an unknown name rolls the whole tree back).';
|
|
23
28
|
|
|
24
29
|
const treeArg = (desc) => z.array(z.record(z.any())).or(z.string()).describe(desc + ACCESS_HINT);
|
|
25
30
|
|
|
@@ -73,6 +78,34 @@ export function registerWorkspaceTools(server) {
|
|
|
73
78
|
const match = await resolveWorkspaceSwitch(workspace, cfg);
|
|
74
79
|
if (match.error) return errorResult(new Error(match.error));
|
|
75
80
|
rememberWorkspace(match, cfg);
|
|
81
|
+
// ISSUE-172 — rememberWorkspace() only writes ~/.rootr/config.json.
|
|
82
|
+
// loadConfig()/keyForWorkspace() both give ROOTR_WORKSPACE/ROOTR_API_KEY
|
|
83
|
+
// env vars priority over that file (deliberately — see config.js
|
|
84
|
+
// ENV-WINS comment), so on a server launched with those env vars set
|
|
85
|
+
// (the common case for MCP client configs that pass env: {...}) every
|
|
86
|
+
// later tool call kept reading the OLD workspace/key from the
|
|
87
|
+
// process's environment no matter what got written to disk. The tool
|
|
88
|
+
// still replied with a success message, so the switch looked like it
|
|
89
|
+
// worked while nothing after it actually moved. Fix: mutate this
|
|
90
|
+
// process's env too, so the very next loadConfig() call (same
|
|
91
|
+
// process, same request loop) picks up the switch immediately.
|
|
92
|
+
if (process.env.ROOTR_WORKSPACE) process.env.ROOTR_WORKSPACE = match.workspace.id;
|
|
93
|
+
if (process.env.ROOTR_API_KEY && match.candidate && match.candidate.apiKey !== process.env.ROOTR_API_KEY) {
|
|
94
|
+
process.env.ROOTR_API_KEY = match.candidate.apiKey;
|
|
95
|
+
}
|
|
96
|
+
// Verify the switch actually took before reporting success — never
|
|
97
|
+
// report a switch that didn't actually move the active workspace.
|
|
98
|
+
const after = loadConfig();
|
|
99
|
+
if (after.workspace !== match.workspace.id) {
|
|
100
|
+
return errorResult(
|
|
101
|
+
new Error(
|
|
102
|
+
`워크스페이스를 "${match.workspace.id}" 로 바꾸지 못했습니다 — 이 서버 프로세스가 ROOTR_WORKSPACE 환경변수로 ` +
|
|
103
|
+
'고정되어 있어 전환이 적용되지 않습니다.\n' +
|
|
104
|
+
'조치: MCP 서버를 실행하는 환경설정에서 ROOTR_WORKSPACE 환경변수를 제거하거나 원하는 워크스페이스 id로 바꾼 뒤 ' +
|
|
105
|
+
'서버를 재시작하세요.',
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
76
109
|
const name = match.workspace.name || match.workspace.slug || '';
|
|
77
110
|
return textResult(`활성 워크스페이스를 "${name}" (${match.workspace.id}) 로 바꿨습니다. 이후 도구 호출은 여기에 적용됩니다.`);
|
|
78
111
|
} catch (err) {
|
|
@@ -190,79 +223,99 @@ export function registerWorkspaceTools(server) {
|
|
|
190
223
|
},
|
|
191
224
|
);
|
|
192
225
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
'
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
226
|
+
// A hosted/remote connector (ChatGPT, Claude web, etc.) is bound to exactly
|
|
227
|
+
// one workspace chosen at OAuth consent time and its token can never carry
|
|
228
|
+
// `workspaces:create` (see MCP_PERMISSION_SCOPES in mcp-oauth.service.ts —
|
|
229
|
+
// deliberately excluded there). Advertising these two tools anyway meant a
|
|
230
|
+
// hosted session always saw rootr_create_workspace/rootr_delete_workspace in
|
|
231
|
+
// tools/list but every call 403'd with "missing scope: workspaces:create" —
|
|
232
|
+
// the tool looked broken with no way to know why. Only the local stdio CLI
|
|
233
|
+
// (PAT-backed, no client override) can ever satisfy that scope, so skip
|
|
234
|
+
// registering them entirely for hosted sessions.
|
|
235
|
+
if (!currentClientOverride()) {
|
|
236
|
+
server.registerTool(
|
|
237
|
+
'rootr_create_workspace',
|
|
238
|
+
{
|
|
239
|
+
title: 'Create a brand-new Rootr workspace from a scaffold tree',
|
|
240
|
+
description:
|
|
241
|
+
'Create an entirely new Rootr (루터) workspace pre-populated with the given document tree. ' +
|
|
242
|
+
'REQUIRES an account key (PAT) with the workspaces:create scope — a workspace-scoped key cannot do this. ' +
|
|
243
|
+
'PRECONDITION: rootr_scaffold_plan first, ask the user its 3-10 clarifying questions, design the tree from ' +
|
|
244
|
+
'their answers, then run rootr_scaffold_review and fix everything it flags at `error` level. The ' +
|
|
245
|
+
'response carries the same `review` back, so a low score here means you shipped something thin. ' +
|
|
246
|
+
'Build a real tool, not a pile of thin markdown files: mix DATABASE (board/' +
|
|
247
|
+
'timeline views, seed rows), SPREADSHEET (formulas), FORM, ISSUE_TRACKER and rich multi-section DOCUMENTs ' +
|
|
248
|
+
'where they fit, and set an emoji `icon` on folders/databases/key documents. The `tree` shape follows ' +
|
|
249
|
+
'the scaffold schema in llms.txt (max 200 nodes). AFTER creating: if your runtime can receive HTTP ' +
|
|
250
|
+
'callbacks, offer the user to register a webhook on the new tree (rootr_create_webhook with scopeNodeId) ' +
|
|
251
|
+
'so changes flow back to you — skip your own actorId to avoid self-triggering.',
|
|
252
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
253
|
+
inputSchema: {
|
|
254
|
+
name: z.string().describe('Name for the new workspace'),
|
|
255
|
+
intent: z.string().optional().describe('Original natural-language intent, for reference/regeneration'),
|
|
256
|
+
tree: treeArg('Scaffold tree — an ARRAY of node objects to populate the new workspace with (see llms.txt tree schema)'),
|
|
257
|
+
mode: z
|
|
258
|
+
.enum(['docs', 'minimal'])
|
|
259
|
+
.optional()
|
|
260
|
+
.describe(
|
|
261
|
+
'Lower the review bar when the system-building rules do not apply: "docs" for a document-only ' +
|
|
262
|
+
'tree with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s ' +
|
|
263
|
+
'shared ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for ' +
|
|
264
|
+
'normal multi-table workspaces.',
|
|
265
|
+
),
|
|
266
|
+
},
|
|
214
267
|
},
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
);
|
|
268
|
+
async ({ name, intent, tree, mode }) => {
|
|
269
|
+
try {
|
|
270
|
+
const client = makeClient();
|
|
271
|
+
const result = await client.createWorkspaceScaffold({ name, intent, tree: parseTree(tree), mode });
|
|
272
|
+
return jsonResult(result);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return errorResult(err);
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
);
|
|
226
278
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
279
|
+
/**
|
|
280
|
+
* WS-CLEANUP — **짓다 실패해서 남은 빈 워크스페이스를 에이전트가 치울 수 있게.**
|
|
281
|
+
*
|
|
282
|
+
* 스캐폴딩이 중간에 실패하면 껍데기만 남는데 지우는 문이 없었다. 무료 한도가
|
|
283
|
+
* 5개라 몇 번 실패하면 사용자가 바로 막히고, 그 잔해를 사람이 화면에서 하나씩
|
|
284
|
+
* 지워야 했다(2026-08-30 실측: 치과 3개·아파트 4개).
|
|
285
|
+
*
|
|
286
|
+
* 이름을 그대로 다시 적게 한다 — 서버가 요구하는 안전장치이자, 모델이 엉뚱한
|
|
287
|
+
* 워크스페이스를 지목했을 때 걸리는 그물이다.
|
|
288
|
+
*/
|
|
289
|
+
server.registerTool(
|
|
290
|
+
'rootr_delete_workspace',
|
|
291
|
+
{
|
|
292
|
+
title: 'Delete a workspace you created (cleanup after a failed scaffold)',
|
|
293
|
+
description:
|
|
294
|
+
'Permanently delete a Rootr (루터) workspace. Intended for cleaning up EMPTY leftovers from a scaffold ' +
|
|
295
|
+
'run that failed halfway — the free plan allows only a handful of workspaces, so abandoned shells block ' +
|
|
296
|
+
'the user from trying again. Requires OWNER on that workspace, and you must pass `confirmName` exactly ' +
|
|
297
|
+
'matching the workspace name (call rootr_workspaces first to read it). THIS CANNOT BE UNDONE: everything ' +
|
|
298
|
+
'inside is removed. Never call this on a workspace that holds the user\'s real work — if you did not ' +
|
|
299
|
+
'create it in this session, ask the user first.',
|
|
300
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
301
|
+
inputSchema: {
|
|
302
|
+
workspaceId: z.string().describe('Id of the workspace to delete'),
|
|
303
|
+
confirmName: z
|
|
304
|
+
.string()
|
|
305
|
+
.describe('The workspace name, typed again exactly — the server rejects the call if it does not match'),
|
|
306
|
+
},
|
|
254
307
|
},
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
308
|
+
async ({ workspaceId, confirmName }) => {
|
|
309
|
+
try {
|
|
310
|
+
const client = makeClient();
|
|
311
|
+
await client.deleteWorkspace({ workspaceId, confirmName });
|
|
312
|
+
return jsonResult({ deleted: true, workspaceId });
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return errorResult(err);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
}
|
|
266
319
|
|
|
267
320
|
server.registerTool(
|
|
268
321
|
'rootr_scaffold_review',
|
|
@@ -280,12 +333,21 @@ export function registerWorkspaceTools(server) {
|
|
|
280
333
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
281
334
|
inputSchema: {
|
|
282
335
|
tree: treeArg('The scaffold tree you designed — an ARRAY of node objects, same shape you would pass to rootr_scaffold_apply'),
|
|
336
|
+
mode: z
|
|
337
|
+
.enum(['docs', 'minimal'])
|
|
338
|
+
.optional()
|
|
339
|
+
.describe(
|
|
340
|
+
'Lower the review bar when the system-building rules do not apply: "docs" for a document-only tree ' +
|
|
341
|
+
'with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s shared ' +
|
|
342
|
+
'ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for normal ' +
|
|
343
|
+
'multi-table workspaces.',
|
|
344
|
+
),
|
|
283
345
|
},
|
|
284
346
|
},
|
|
285
|
-
async ({ tree }) => {
|
|
347
|
+
async ({ tree, mode }) => {
|
|
286
348
|
try {
|
|
287
349
|
const client = makeClient();
|
|
288
|
-
const result = await client.scaffoldReview(parseTree(tree));
|
|
350
|
+
const result = await client.scaffoldReview(parseTree(tree), mode);
|
|
289
351
|
return jsonResult(result);
|
|
290
352
|
} catch (err) {
|
|
291
353
|
return errorResult(err);
|
|
@@ -309,13 +371,20 @@ export function registerWorkspaceTools(server) {
|
|
|
309
371
|
inputSchema: {
|
|
310
372
|
rootPath: z.string().optional().describe('Grade only this subtree, e.g. "/동네 빵집 운영"'),
|
|
311
373
|
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
374
|
+
mode: z
|
|
375
|
+
.enum(['docs', 'minimal'])
|
|
376
|
+
.optional()
|
|
377
|
+
.describe(
|
|
378
|
+
'Same meaning as on rootr_scaffold_review: "docs" for a document-only subtree, "minimal" for a ' +
|
|
379
|
+
'small personal/few-person subtree where relations/guide/forms/rollups are overkill.',
|
|
380
|
+
),
|
|
312
381
|
},
|
|
313
382
|
},
|
|
314
|
-
async ({ rootPath, workspace }) => {
|
|
383
|
+
async ({ rootPath, workspace, mode }) => {
|
|
315
384
|
try {
|
|
316
385
|
const ws = requireWorkspace(workspace);
|
|
317
386
|
const client = makeClient(ws);
|
|
318
|
-
return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath));
|
|
387
|
+
return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath, mode));
|
|
319
388
|
} catch (err) {
|
|
320
389
|
return errorResult(err);
|
|
321
390
|
}
|
|
@@ -373,13 +442,22 @@ export function registerWorkspaceTools(server) {
|
|
|
373
442
|
.string()
|
|
374
443
|
.optional()
|
|
375
444
|
.describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
445
|
+
mode: z
|
|
446
|
+
.enum(['docs', 'minimal'])
|
|
447
|
+
.optional()
|
|
448
|
+
.describe(
|
|
449
|
+
'Lower the review bar when the system-building rules do not apply: "docs" for a document-only tree ' +
|
|
450
|
+
'with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s shared ' +
|
|
451
|
+
'ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for normal ' +
|
|
452
|
+
'multi-table workspaces.',
|
|
453
|
+
),
|
|
376
454
|
},
|
|
377
455
|
},
|
|
378
|
-
async ({ tree, rootPath, workspace }) => {
|
|
456
|
+
async ({ tree, rootPath, workspace, mode }) => {
|
|
379
457
|
try {
|
|
380
458
|
const ws = requireWorkspace(workspace);
|
|
381
459
|
const client = makeClient(ws);
|
|
382
|
-
const result = await client.scaffoldApply(ws, { rootPath, tree: parseTree(tree) });
|
|
460
|
+
const result = await client.scaffoldApply(ws, { rootPath, tree: parseTree(tree), mode });
|
|
383
461
|
return jsonResult(result);
|
|
384
462
|
} catch (err) {
|
|
385
463
|
return errorResult(err);
|
package/lib/mcp.js
CHANGED
|
@@ -11,6 +11,8 @@ import { registerTemplateTools } from './mcp-tools/templates.js';
|
|
|
11
11
|
import { registerWebhookTools } from './mcp-tools/webhooks.js';
|
|
12
12
|
import { registerMiscTools } from './mcp-tools/misc.js';
|
|
13
13
|
import { registerPublicTools } from './mcp-tools/public.js';
|
|
14
|
+
// BOOKING — 공개한 표를 예약 페이지로 쓰는 도구 + 구글 캘린더 연결.
|
|
15
|
+
import { registerBookingTools } from './mcp-tools/booking.js';
|
|
14
16
|
import { registerCrmTools } from './mcp-tools/crm.js';
|
|
15
17
|
import { registerPresentationTools } from './mcp-tools/presentations.js';
|
|
16
18
|
import { registerPageTools } from './mcp-tools/pages.js';
|
|
@@ -272,6 +274,7 @@ export function buildMcpServer({ name, version }) {
|
|
|
272
274
|
registerWebhookTools(server);
|
|
273
275
|
registerMiscTools(server);
|
|
274
276
|
registerPublicTools(server);
|
|
277
|
+
registerBookingTools(server);
|
|
275
278
|
registerCrmTools(server);
|
|
276
279
|
registerPresentationTools(server);
|
|
277
280
|
registerPageTools(server);
|
package/lib/resolve.js
CHANGED
|
@@ -30,6 +30,26 @@ export function parseRootrUrl(target) {
|
|
|
30
30
|
return { workspaceId: segments[at + 1], nodeId: id, kind: id ? kind : undefined };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Parse a published public document URL: `rootr.io/p/{slug}` (optionally
|
|
35
|
+
* locale-prefixed, e.g. `/ko/p/{slug}`). This is the "share this doc"
|
|
36
|
+
* address — it carries no workspace id, unlike `/w/{ws}/...` URLs, so it
|
|
37
|
+
* needs its own recognizer. Returns null for anything else.
|
|
38
|
+
*/
|
|
39
|
+
export function parsePublicSlugUrl(target) {
|
|
40
|
+
if (typeof target !== 'string' || !/^https?:\/\//i.test(target)) return null;
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(target);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
48
|
+
const at = segments.indexOf('p');
|
|
49
|
+
if (at === -1 || !segments[at + 1]) return null;
|
|
50
|
+
return { slug: segments[at + 1] };
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
/**
|
|
34
54
|
* Resolve a CLI/MCP "target" argument to a node id.
|
|
35
55
|
* A full rootr.io URL is unpacked to the node id it points at; a value
|
|
@@ -78,6 +98,27 @@ export async function resolveTarget(client, target) {
|
|
|
78
98
|
return { id: url.nodeId, existed: true, path: undefined, url };
|
|
79
99
|
}
|
|
80
100
|
|
|
101
|
+
const publicUrl = parsePublicSlugUrl(target);
|
|
102
|
+
if (publicUrl) {
|
|
103
|
+
if (!client.workspace) {
|
|
104
|
+
throw new RootrApiError(
|
|
105
|
+
tr(
|
|
106
|
+
`이 주소는 공개 문서 주소(/p/${publicUrl.slug})입니다. 먼저 rootr_use_workspace 로 워크스페이스를 선택하세요.`,
|
|
107
|
+
`This is a public document URL (/p/${publicUrl.slug}). Select a workspace with rootr_use_workspace first.`,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const node = await client.getNodeBySlug(publicUrl.slug);
|
|
112
|
+
if (!node) {
|
|
113
|
+
// ISSUE-127 — 못 찾은 공개 주소는 "새로 만들 경로"가 아니다. 예전에는 path 에 주소를 그대로 실어, rootr_write 가
|
|
114
|
+
// `/https:/rootr.io/p/<slug>` 라는 폴더 줄기와 헛문서를 만들었다(격리 dev 실측). path 는 비우고 주소는 target 으로만 남긴다.
|
|
115
|
+
// publicSlug 는 rootr_read 가 공개 발행본으로 되받을 때만 쓴다(쓰기 도구는 안 본다).
|
|
116
|
+
return { id: undefined, existed: false, path: undefined, target, publicSlug: publicUrl.slug };
|
|
117
|
+
}
|
|
118
|
+
const id = node.id || (node.node && node.node.id);
|
|
119
|
+
return { id, existed: true, path: undefined, node: node.node || node };
|
|
120
|
+
}
|
|
121
|
+
|
|
81
122
|
if (!target.startsWith('/')) {
|
|
82
123
|
return { id: target, existed: true, path: undefined };
|
|
83
124
|
}
|
|
@@ -92,7 +133,8 @@ export async function resolveTarget(client, target) {
|
|
|
92
133
|
|
|
93
134
|
export function requireResolved(resolved) {
|
|
94
135
|
if (!resolved.existed || !resolved.id) {
|
|
95
|
-
|
|
136
|
+
const what = resolved.path ?? resolved.target;
|
|
137
|
+
throw new RootrApiError(tr(`경로를 찾을 수 없습니다: ${what}`, `Path not found: ${what}`));
|
|
96
138
|
}
|
|
97
139
|
return resolved.id;
|
|
98
140
|
}
|