@xano/cli 1.0.6-beta.1 → 1.0.6-beta.3
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.md +1 -0
- package/dist/commands/ephemeral/impersonate/index.d.ts +1 -0
- package/dist/commands/ephemeral/impersonate/index.js +11 -3
- package/dist/commands/tenant/pull/index.js +51 -1
- package/dist/commands/workspace/git/pull/index.js +53 -3
- package/dist/commands/workspace/pull/index.js +53 -1
- package/dist/utils/document-parser.d.ts +46 -0
- package/dist/utils/document-parser.js +75 -1
- package/dist/utils/knowledge-sync.d.ts +1 -0
- package/dist/utils/knowledge-sync.js +11 -5
- package/dist/utils/reference-checker.js +13 -0
- package/oclif.manifest.json +2250 -2241
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -594,6 +594,7 @@ xano ephemeral push <tenant_name> -d ./my-ephemeral
|
|
|
594
594
|
# Open in the browser (or print the URL)
|
|
595
595
|
xano ephemeral impersonate <tenant_name>
|
|
596
596
|
xano ephemeral impersonate <tenant_name> --url-only
|
|
597
|
+
xano ephemeral impersonate <tenant_name> --guest # read-only session (browse only)
|
|
597
598
|
```
|
|
598
599
|
|
|
599
600
|
#### Static hosting for an ephemeral tenant
|
|
@@ -6,6 +6,7 @@ export default class EphemeralImpersonate extends BaseCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
+
guest: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
11
|
'url-only': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
12
|
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -15,9 +15,16 @@ Opening browser...
|
|
|
15
15
|
Impersonation successful!
|
|
16
16
|
`,
|
|
17
17
|
`$ xano ephemeral impersonate e4f2-9ab1-xyz1 -o json`,
|
|
18
|
+
`$ xano ephemeral impersonate e4f2-9ab1-xyz1 --guest --url-only`,
|
|
18
19
|
];
|
|
19
20
|
static flags = {
|
|
20
21
|
...BaseCommand.baseFlags,
|
|
22
|
+
guest: Flags.boolean({
|
|
23
|
+
char: 'g',
|
|
24
|
+
default: false,
|
|
25
|
+
description: 'Mint a read-only guest session (browse only; no schema/data/config changes)',
|
|
26
|
+
required: false,
|
|
27
|
+
}),
|
|
21
28
|
output: Flags.string({
|
|
22
29
|
char: 'o',
|
|
23
30
|
default: 'summary',
|
|
@@ -46,7 +53,7 @@ Impersonation successful!
|
|
|
46
53
|
}
|
|
47
54
|
const tenantName = args.tenant_name;
|
|
48
55
|
try {
|
|
49
|
-
const response = await this.getImpersonateResponse(profile, workspaceId, tenantName);
|
|
56
|
+
const response = await this.getImpersonateResponse(profile, workspaceId, tenantName, flags.guest);
|
|
50
57
|
if (flags.output === 'json') {
|
|
51
58
|
this.log(JSON.stringify(response, null, 2));
|
|
52
59
|
}
|
|
@@ -76,8 +83,9 @@ Impersonation successful!
|
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
|
-
async getImpersonateResponse(profile, workspaceId, tenantName) {
|
|
80
|
-
const
|
|
86
|
+
async getImpersonateResponse(profile, workspaceId, tenantName, guest = false) {
|
|
87
|
+
const query = guest ? '?guest_read_only=true' : '';
|
|
88
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${encodeURIComponent(tenantName)}/impersonate${query}`;
|
|
81
89
|
const { verbose } = await this.parse(EphemeralImpersonate).then((r) => r.flags);
|
|
82
90
|
const response = await this.verboseFetch(apiUrl, {
|
|
83
91
|
headers: {
|
|
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import snakeCase from 'lodash.snakecase';
|
|
5
5
|
import BaseCommand from '../../../base-command.js';
|
|
6
|
-
import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
|
|
6
|
+
import { buildApiGroupFolderResolver, buildChannelServerResolver, channelPathSegments, parseDocument, } from '../../../utils/document-parser.js';
|
|
7
7
|
export default class Pull extends BaseCommand {
|
|
8
8
|
static description = 'Pull a tenant multidoc from the Xano Metadata API and split into individual files';
|
|
9
9
|
static examples = [
|
|
@@ -129,6 +129,9 @@ Pulled 58 documents from tenant my-tenant
|
|
|
129
129
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
130
130
|
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
131
131
|
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
132
|
+
// Resolve a realtime v2 channel path -> owning realtime_server name, so
|
|
133
|
+
// messages can nest under their channel's server (see resolver docs).
|
|
134
|
+
const getChannelServer = buildChannelServerResolver(documents);
|
|
132
135
|
// Track filenames per type to handle duplicates
|
|
133
136
|
const filenameCounters = new Map();
|
|
134
137
|
let writtenCount = 0;
|
|
@@ -190,6 +193,53 @@ Pulled 58 documents from tenant my-tenant
|
|
|
190
193
|
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
191
194
|
baseName = this.sanitizeFilename(doc.name);
|
|
192
195
|
}
|
|
196
|
+
else if (doc.type === 'realtime_server') {
|
|
197
|
+
// Realtime v2 — see workspace/pull for the full rationale. Its own
|
|
198
|
+
// document is named after itself, mirroring api_group.
|
|
199
|
+
// realtime_server "chat" → realtime/server/chat/chat.xs
|
|
200
|
+
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
|
|
201
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
202
|
+
}
|
|
203
|
+
else if (doc.type === 'channel') {
|
|
204
|
+
// Realtime v2. The channel owns a directory named after itself (the full
|
|
205
|
+
// channel name, snake_cased into a single flat segment — like every other
|
|
206
|
+
// object), and its messages live in a message/ subfolder inside it. It
|
|
207
|
+
// nests under its owning realtime_server (from `server = "..."`):
|
|
208
|
+
// channel "rooms/{room_id}" (server "chat")
|
|
209
|
+
// → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
|
|
210
|
+
//
|
|
211
|
+
// The channel name is snake_cased as a whole (rooms/{room_id} →
|
|
212
|
+
// rooms_room_id), so there is no path nesting and no bracket escaping —
|
|
213
|
+
// the on-disk name is a display form; the .xs content keeps the real
|
|
214
|
+
// name. A channel with no resolvable server (e.g. a pre-server export)
|
|
215
|
+
// falls back to the legacy flat channel/<path>/_channel.xs layout.
|
|
216
|
+
if (doc.server) {
|
|
217
|
+
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
|
|
218
|
+
baseName = snakeCase(doc.name);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
|
|
222
|
+
baseName = '_channel';
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (doc.type === 'message' && doc.channel) {
|
|
226
|
+
// Realtime v2 message → nests in a message/ subfolder under its channel,
|
|
227
|
+
// under that channel's server. The message names only its channel; the
|
|
228
|
+
// server is resolved by looking that channel up in the same multidoc
|
|
229
|
+
// (two-pass resolve).
|
|
230
|
+
// message "post" on channel "rooms/{room_id}" (server "chat")
|
|
231
|
+
// → realtime/server/chat/channel/rooms_room_id/message/post.xs
|
|
232
|
+
//
|
|
233
|
+
// Nesting matters beyond tidiness: message names are unique only WITHIN
|
|
234
|
+
// a channel, so a flat message/ directory would collide when two
|
|
235
|
+
// channels both define e.g. "say". A channel whose server can't be
|
|
236
|
+
// resolved falls back to the legacy flat channel/ layout.
|
|
237
|
+
const messageServer = getChannelServer(doc.channel);
|
|
238
|
+
typeDir = messageServer
|
|
239
|
+
? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
|
|
240
|
+
: path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
|
|
241
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
242
|
+
}
|
|
193
243
|
else if (doc.type === 'api_group') {
|
|
194
244
|
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
195
245
|
const groupFolder = getApiGroupFolder(doc.name);
|
|
@@ -5,7 +5,7 @@ import * as os from 'node:os';
|
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import snakeCase from 'lodash.snakecase';
|
|
7
7
|
import BaseCommand, { buildUserAgent } from '../../../../base-command.js';
|
|
8
|
-
import { buildApiGroupFolderResolver, parseDocument } from '../../../../utils/document-parser.js';
|
|
8
|
+
import { buildApiGroupFolderResolver, buildChannelServerResolver, channelPathSegments, parseDocument, } from '../../../../utils/document-parser.js';
|
|
9
9
|
export default class GitPull extends BaseCommand {
|
|
10
10
|
static description = 'Pull XanoScript files from a git repository into a local directory';
|
|
11
11
|
static examples = [
|
|
@@ -97,10 +97,13 @@ export default class GitPull extends BaseCommand {
|
|
|
97
97
|
// Write documents to output directory using the same file tree logic as workspace pull
|
|
98
98
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
99
99
|
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
100
|
+
// Resolve a realtime v2 channel path -> owning realtime_server name, so
|
|
101
|
+
// messages can nest under their channel's server (see resolver docs).
|
|
102
|
+
const getChannelServer = buildChannelServerResolver(documents);
|
|
100
103
|
const filenameCounters = new Map();
|
|
101
104
|
let writtenCount = 0;
|
|
102
105
|
for (const doc of documents) {
|
|
103
|
-
const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder);
|
|
106
|
+
const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer);
|
|
104
107
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
105
108
|
// Track duplicates per directory
|
|
106
109
|
const dirKey = path.relative(outputDir, typeDir);
|
|
@@ -325,7 +328,7 @@ export default class GitPull extends BaseCommand {
|
|
|
325
328
|
* Resolve the output directory and base filename for a parsed document.
|
|
326
329
|
* Uses the same type-to-directory mapping as workspace pull.
|
|
327
330
|
*/
|
|
328
|
-
resolveOutputPath(outputDir, doc, getApiGroupFolder) {
|
|
331
|
+
resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer) {
|
|
329
332
|
let typeDir;
|
|
330
333
|
let baseName;
|
|
331
334
|
if (doc.type === 'workspace') {
|
|
@@ -373,6 +376,53 @@ export default class GitPull extends BaseCommand {
|
|
|
373
376
|
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
374
377
|
baseName = this.sanitizeFilename(doc.name);
|
|
375
378
|
}
|
|
379
|
+
else if (doc.type === 'realtime_server') {
|
|
380
|
+
// Realtime v2 — see workspace/pull for the full rationale. Its own
|
|
381
|
+
// document is named after itself, mirroring api_group.
|
|
382
|
+
// realtime_server "chat" → realtime/server/chat/chat.xs
|
|
383
|
+
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
|
|
384
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
385
|
+
}
|
|
386
|
+
else if (doc.type === 'channel') {
|
|
387
|
+
// Realtime v2. The channel owns a directory named after itself (the full
|
|
388
|
+
// channel name, snake_cased into a single flat segment — like every other
|
|
389
|
+
// object), and its messages live in a message/ subfolder inside it. It
|
|
390
|
+
// nests under its owning realtime_server (from `server = "..."`):
|
|
391
|
+
// channel "rooms/{room_id}" (server "chat")
|
|
392
|
+
// → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
|
|
393
|
+
//
|
|
394
|
+
// The channel name is snake_cased as a whole (rooms/{room_id} →
|
|
395
|
+
// rooms_room_id), so there is no path nesting and no bracket escaping —
|
|
396
|
+
// the on-disk name is a display form; the .xs content keeps the real
|
|
397
|
+
// name. A channel with no resolvable server (e.g. a pre-server export)
|
|
398
|
+
// falls back to the legacy flat channel/<path>/_channel.xs layout.
|
|
399
|
+
if (doc.server) {
|
|
400
|
+
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
|
|
401
|
+
baseName = snakeCase(doc.name);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
|
|
405
|
+
baseName = '_channel';
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else if (doc.type === 'message' && doc.channel) {
|
|
409
|
+
// Realtime v2 message → nests in a message/ subfolder under its channel,
|
|
410
|
+
// under that channel's server. The message names only its channel; the
|
|
411
|
+
// server is resolved by looking that channel up in the same multidoc
|
|
412
|
+
// (two-pass resolve).
|
|
413
|
+
// message "post" on channel "rooms/{room_id}" (server "chat")
|
|
414
|
+
// → realtime/server/chat/channel/rooms_room_id/message/post.xs
|
|
415
|
+
//
|
|
416
|
+
// Nesting matters beyond tidiness: message names are unique only WITHIN
|
|
417
|
+
// a channel, so a flat message/ directory would collide when two
|
|
418
|
+
// channels both define e.g. "say". A channel whose server can't be
|
|
419
|
+
// resolved falls back to the legacy flat channel/ layout.
|
|
420
|
+
const messageServer = getChannelServer(doc.channel);
|
|
421
|
+
typeDir = messageServer
|
|
422
|
+
? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
|
|
423
|
+
: path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
|
|
424
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
425
|
+
}
|
|
376
426
|
else if (doc.type === 'api_group') {
|
|
377
427
|
const groupFolder = getApiGroupFolder(doc.name);
|
|
378
428
|
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import snakeCase from 'lodash.snakecase';
|
|
5
5
|
import BaseCommand from '../../../base-command.js';
|
|
6
|
-
import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
|
|
6
|
+
import { buildApiGroupFolderResolver, buildChannelServerResolver, channelPathSegments, parseDocument, } from '../../../utils/document-parser.js';
|
|
7
7
|
import { fetchKnowledge, writeKnowledge } from '../../../utils/knowledge-sync.js';
|
|
8
8
|
export default class Pull extends BaseCommand {
|
|
9
9
|
static description = 'Pull a workspace multidoc from the Xano Metadata API and split into individual files';
|
|
@@ -134,6 +134,9 @@ Pulled 58 documents
|
|
|
134
134
|
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
135
135
|
// where different names produce the same snakeCase (e.g., "Authentication" vs "authentication")
|
|
136
136
|
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
137
|
+
// Resolve a realtime v2 channel path -> owning realtime_server name, so
|
|
138
|
+
// messages can nest under their channel's server (see resolver docs).
|
|
139
|
+
const getChannelServer = buildChannelServerResolver(documents);
|
|
137
140
|
// Track filenames per type to handle duplicates
|
|
138
141
|
const filenameCounters = new Map();
|
|
139
142
|
let writtenCount = 0;
|
|
@@ -195,6 +198,55 @@ Pulled 58 documents
|
|
|
195
198
|
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
196
199
|
baseName = this.sanitizeFilename(doc.name);
|
|
197
200
|
}
|
|
201
|
+
else if (doc.type === 'realtime_server') {
|
|
202
|
+
// Realtime v2. The realtime_server is the top-level container that owns
|
|
203
|
+
// channels (which own messages). Its own document is named after itself
|
|
204
|
+
// inside its name directory, mirroring api_group (api/<group>/<group>.xs)
|
|
205
|
+
// — the server name is a simple segment, so no fixed leaf is needed.
|
|
206
|
+
// realtime_server "chat" → realtime/server/chat/chat.xs
|
|
207
|
+
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
|
|
208
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
209
|
+
}
|
|
210
|
+
else if (doc.type === 'channel') {
|
|
211
|
+
// Realtime v2. The channel owns a directory named after itself (the full
|
|
212
|
+
// channel name, snake_cased into a single flat segment — like every other
|
|
213
|
+
// object), and its messages live in a message/ subfolder inside it. It
|
|
214
|
+
// nests under its owning realtime_server (from `server = "..."`):
|
|
215
|
+
// channel "rooms/{room_id}" (server "chat")
|
|
216
|
+
// → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
|
|
217
|
+
//
|
|
218
|
+
// The channel name is snake_cased as a whole (rooms/{room_id} →
|
|
219
|
+
// rooms_room_id), so there is no path nesting and no bracket escaping —
|
|
220
|
+
// the on-disk name is a display form; the .xs content keeps the real
|
|
221
|
+
// name. A channel with no resolvable server (e.g. a pre-server export)
|
|
222
|
+
// falls back to the legacy flat channel/<path>/_channel.xs layout.
|
|
223
|
+
if (doc.server) {
|
|
224
|
+
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
|
|
225
|
+
baseName = snakeCase(doc.name);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
|
|
229
|
+
baseName = '_channel';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (doc.type === 'message' && doc.channel) {
|
|
233
|
+
// Realtime v2 message → nests in a message/ subfolder under its channel,
|
|
234
|
+
// under that channel's server. The message names only its channel; the
|
|
235
|
+
// server is resolved by looking that channel up in the same multidoc
|
|
236
|
+
// (two-pass resolve).
|
|
237
|
+
// message "post" on channel "rooms/{room_id}" (server "chat")
|
|
238
|
+
// → realtime/server/chat/channel/rooms_room_id/message/post.xs
|
|
239
|
+
//
|
|
240
|
+
// Nesting matters beyond tidiness: message names are unique only WITHIN
|
|
241
|
+
// a channel, so a flat message/ directory would collide when two
|
|
242
|
+
// channels both define e.g. "say". A channel whose server can't be
|
|
243
|
+
// resolved falls back to the legacy flat channel/ layout.
|
|
244
|
+
const messageServer = getChannelServer(doc.channel);
|
|
245
|
+
typeDir = messageServer
|
|
246
|
+
? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
|
|
247
|
+
: path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
|
|
248
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
249
|
+
}
|
|
198
250
|
else if (doc.type === 'api_group') {
|
|
199
251
|
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
200
252
|
const groupFolder = getApiGroupFolder(doc.name);
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export interface ParsedDocument {
|
|
2
2
|
apiGroup?: string;
|
|
3
3
|
canonical?: string;
|
|
4
|
+
/** Owning channel path for realtime v2 `message` documents. */
|
|
5
|
+
channel?: string;
|
|
4
6
|
content: string;
|
|
5
7
|
guid?: string;
|
|
6
8
|
name: string;
|
|
9
|
+
/** Owning realtime_server name for realtime v2 `channel` documents. */
|
|
10
|
+
server?: string;
|
|
7
11
|
type: string;
|
|
8
12
|
verb?: string;
|
|
9
13
|
}
|
|
@@ -12,6 +16,48 @@ export interface ParsedDocument {
|
|
|
12
16
|
* Skips leading comment lines (starting with //) to find the first meaningful line.
|
|
13
17
|
*/
|
|
14
18
|
export declare function parseDocument(content: string): null | ParsedDocument;
|
|
19
|
+
/**
|
|
20
|
+
* Map a realtime v2 channel path onto directory segments.
|
|
21
|
+
*
|
|
22
|
+
* A channel name IS a path template — "rooms/{room_id}" — so the directory tree
|
|
23
|
+
* mirrors it rather than flattening it. That is the whole reason messages nest
|
|
24
|
+
* under channels: the tree should show the structure, and
|
|
25
|
+
* "channel/rooms/[room_id]/post.xs" says immediately that `post` lives on a
|
|
26
|
+
* parameterized rooms channel.
|
|
27
|
+
*
|
|
28
|
+
* "rooms" -> ["rooms"]
|
|
29
|
+
* "rooms/{room_id}" -> ["rooms", "[room_id]"]
|
|
30
|
+
* "org/{org_id}/room/{id}" -> ["org", "[org_id]", "room", "[id]"]
|
|
31
|
+
*
|
|
32
|
+
* Braces become brackets because "{" and "}" are awkward in shells, while "["
|
|
33
|
+
* and "]" are legal on every filesystem. They are still glob character classes,
|
|
34
|
+
* so scripting against a checkout needs the path quoted.
|
|
35
|
+
*
|
|
36
|
+
* Escaping the braces rather than dropping them is what keeps a parameterized
|
|
37
|
+
* channel distinct from a literal one: without it, "rooms/{room_id}" and a
|
|
38
|
+
* literal channel named "rooms/room_id" collide on the same directory.
|
|
39
|
+
*
|
|
40
|
+
* Mirrors Vfs::channelSegment() on the server.
|
|
41
|
+
*/
|
|
42
|
+
export declare function channelPathSegments(name: string, snakeCaseFn: (s: string) => string): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Build a map of realtime v2 channel path -> owning realtime_server name.
|
|
45
|
+
*
|
|
46
|
+
* This is the cross-document lookup the on-disk nesting needs: a `message`
|
|
47
|
+
* document names its channel (`channel = "..."`), while a `channel` document
|
|
48
|
+
* names its server (`realtime_server = "..."`). To place a message under
|
|
49
|
+
* `realtime/server/<server>/channel/<path>/...` we resolve the message's channel
|
|
50
|
+
* to that channel's server — which the channel document carries. (Messages now
|
|
51
|
+
* also carry `realtime_server` directly, but resolving via the channel keeps a
|
|
52
|
+
* single source of truth for the channel→server mapping.)
|
|
53
|
+
*
|
|
54
|
+
* Because every pull path parses the WHOLE multidoc into memory before writing,
|
|
55
|
+
* we can build this map from the `channel` docs in the same batch (a two-pass
|
|
56
|
+
* resolve), keyed by the channel's own name (its path). A channel with no
|
|
57
|
+
* `realtime_server` (e.g. a pre-server export) simply isn't in the map, and
|
|
58
|
+
* callers fall back accordingly.
|
|
59
|
+
*/
|
|
60
|
+
export declare function buildChannelServerResolver(documents: ParsedDocument[]): (channelName: string) => string | undefined;
|
|
15
61
|
/**
|
|
16
62
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
17
63
|
* Used to match server GUID map entries back to local files.
|
|
@@ -45,6 +45,25 @@ export function parseDocument(content) {
|
|
|
45
45
|
if (apiGroupMatch) {
|
|
46
46
|
apiGroup = apiGroupMatch[1];
|
|
47
47
|
}
|
|
48
|
+
// Extract channel if present (e.g., channel = "rooms/{room_id}").
|
|
49
|
+
// Realtime v2 messages reference their owning channel by PATH, which is what
|
|
50
|
+
// nests them under it on disk.
|
|
51
|
+
let channel;
|
|
52
|
+
const channelMatch = content.match(/^\s*channel\s*=\s*"([^"]*)"/m);
|
|
53
|
+
if (channelMatch) {
|
|
54
|
+
channel = channelMatch[1];
|
|
55
|
+
}
|
|
56
|
+
// Extract realtime_server if present (e.g., realtime_server = "chat").
|
|
57
|
+
// Realtime v2 channels/messages reference their owning realtime_server by NAME
|
|
58
|
+
// via a top-level `realtime_server = "..."`, which is what nests them under it
|
|
59
|
+
// on disk (mirrors how a message names its channel via `channel = "..."`).
|
|
60
|
+
// The keyword is `realtime_server` (renamed from a bare `server` to match the
|
|
61
|
+
// api_group/mcp_server convention — reference the container by its full type).
|
|
62
|
+
let server;
|
|
63
|
+
const serverMatch = content.match(/^\s*realtime_server\s*=\s*"([^"]*)"/m);
|
|
64
|
+
if (serverMatch) {
|
|
65
|
+
server = serverMatch[1];
|
|
66
|
+
}
|
|
48
67
|
// Extract canonical if present (e.g., canonical = "abc123")
|
|
49
68
|
let canonical;
|
|
50
69
|
const canonicalMatch = content.match(/canonical\s*=\s*"([^"]*)"/);
|
|
@@ -57,7 +76,62 @@ export function parseDocument(content) {
|
|
|
57
76
|
if (guidMatch) {
|
|
58
77
|
guid = guidMatch[1];
|
|
59
78
|
}
|
|
60
|
-
return { apiGroup, canonical, content, guid, name, type, verb };
|
|
79
|
+
return { apiGroup, canonical, channel, content, guid, name, server, type, verb };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Map a realtime v2 channel path onto directory segments.
|
|
83
|
+
*
|
|
84
|
+
* A channel name IS a path template — "rooms/{room_id}" — so the directory tree
|
|
85
|
+
* mirrors it rather than flattening it. That is the whole reason messages nest
|
|
86
|
+
* under channels: the tree should show the structure, and
|
|
87
|
+
* "channel/rooms/[room_id]/post.xs" says immediately that `post` lives on a
|
|
88
|
+
* parameterized rooms channel.
|
|
89
|
+
*
|
|
90
|
+
* "rooms" -> ["rooms"]
|
|
91
|
+
* "rooms/{room_id}" -> ["rooms", "[room_id]"]
|
|
92
|
+
* "org/{org_id}/room/{id}" -> ["org", "[org_id]", "room", "[id]"]
|
|
93
|
+
*
|
|
94
|
+
* Braces become brackets because "{" and "}" are awkward in shells, while "["
|
|
95
|
+
* and "]" are legal on every filesystem. They are still glob character classes,
|
|
96
|
+
* so scripting against a checkout needs the path quoted.
|
|
97
|
+
*
|
|
98
|
+
* Escaping the braces rather than dropping them is what keeps a parameterized
|
|
99
|
+
* channel distinct from a literal one: without it, "rooms/{room_id}" and a
|
|
100
|
+
* literal channel named "rooms/room_id" collide on the same directory.
|
|
101
|
+
*
|
|
102
|
+
* Mirrors Vfs::channelSegment() on the server.
|
|
103
|
+
*/
|
|
104
|
+
export function channelPathSegments(name, snakeCaseFn) {
|
|
105
|
+
// A parameter keeps the author's name verbatim so it matches the `input`
|
|
106
|
+
// block; only the delimiters are swapped.
|
|
107
|
+
const isParam = /^\{.*\}$/;
|
|
108
|
+
return name.split('/').map((segment) => (isParam.test(segment) ? `[${segment.slice(1, -1)}]` : snakeCaseFn(segment)));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Build a map of realtime v2 channel path -> owning realtime_server name.
|
|
112
|
+
*
|
|
113
|
+
* This is the cross-document lookup the on-disk nesting needs: a `message`
|
|
114
|
+
* document names its channel (`channel = "..."`), while a `channel` document
|
|
115
|
+
* names its server (`realtime_server = "..."`). To place a message under
|
|
116
|
+
* `realtime/server/<server>/channel/<path>/...` we resolve the message's channel
|
|
117
|
+
* to that channel's server — which the channel document carries. (Messages now
|
|
118
|
+
* also carry `realtime_server` directly, but resolving via the channel keeps a
|
|
119
|
+
* single source of truth for the channel→server mapping.)
|
|
120
|
+
*
|
|
121
|
+
* Because every pull path parses the WHOLE multidoc into memory before writing,
|
|
122
|
+
* we can build this map from the `channel` docs in the same batch (a two-pass
|
|
123
|
+
* resolve), keyed by the channel's own name (its path). A channel with no
|
|
124
|
+
* `realtime_server` (e.g. a pre-server export) simply isn't in the map, and
|
|
125
|
+
* callers fall back accordingly.
|
|
126
|
+
*/
|
|
127
|
+
export function buildChannelServerResolver(documents) {
|
|
128
|
+
const channelToServer = new Map();
|
|
129
|
+
for (const doc of documents) {
|
|
130
|
+
if (doc.type === 'channel' && doc.server) {
|
|
131
|
+
channelToServer.set(doc.name, doc.server);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return (channelName) => channelToServer.get(channelName);
|
|
61
135
|
}
|
|
62
136
|
/**
|
|
63
137
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
@@ -17,6 +17,7 @@ export interface KnowledgeObject {
|
|
|
17
17
|
mode?: string;
|
|
18
18
|
name: string;
|
|
19
19
|
scope?: string;
|
|
20
|
+
tag?: string[];
|
|
20
21
|
}
|
|
21
22
|
/** A locally-collected object that also remembers its primary file on disk (for GUID writeback). */
|
|
22
23
|
export interface LocalKnowledgeObject extends KnowledgeObject {
|
|
@@ -12,7 +12,7 @@ import { dirname, join, relative, sep } from 'node:path';
|
|
|
12
12
|
// let the server dictate file paths.
|
|
13
13
|
export const KNOWLEDGE_DIR = 'knowledge';
|
|
14
14
|
// ── Frontmatter ──────────────────────────────────────────────────────────────
|
|
15
|
-
const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', '
|
|
15
|
+
const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', 'inclusion', 'tags', 'guid'];
|
|
16
16
|
/**
|
|
17
17
|
* Build a primary `.md` file body: YAML frontmatter (built from the object's
|
|
18
18
|
* structured fields) followed by the markdown content.
|
|
@@ -23,12 +23,10 @@ export function buildPrimaryContent(obj) {
|
|
|
23
23
|
if (obj.description !== undefined)
|
|
24
24
|
meta.description = obj.description;
|
|
25
25
|
meta.knowledge_type = obj.knowledge_type;
|
|
26
|
-
if (obj.scope !== undefined)
|
|
27
|
-
meta.scope = obj.scope;
|
|
28
26
|
if (obj.mode !== undefined)
|
|
29
27
|
meta.inclusion = modeToInclusion(obj.mode);
|
|
30
|
-
if (obj.
|
|
31
|
-
meta.
|
|
28
|
+
if (obj.tag !== undefined && obj.tag.length > 0)
|
|
29
|
+
meta.tags = obj.tag;
|
|
32
30
|
if (obj.guid !== undefined)
|
|
33
31
|
meta.guid = obj.guid;
|
|
34
32
|
// Emit keys in a stable, human-friendly order.
|
|
@@ -107,6 +105,10 @@ const MODE_TO_INCLUSION = {
|
|
|
107
105
|
function modeToInclusion(mode) {
|
|
108
106
|
return MODE_TO_INCLUSION[mode] ?? mode;
|
|
109
107
|
}
|
|
108
|
+
/** Parse the on-disk `tags:` frontmatter value into a string array, or `undefined` if absent/malformed. */
|
|
109
|
+
function parseTags(tags) {
|
|
110
|
+
return Array.isArray(tags) && tags.every((t) => typeof t === 'string') ? tags : undefined;
|
|
111
|
+
}
|
|
110
112
|
// ── Path mapping (CLI-owned layout) ────────────────────────────────────────────
|
|
111
113
|
/**
|
|
112
114
|
* Allocate a unique snake_case folder/file segment per call, suffixing
|
|
@@ -246,6 +248,9 @@ export function collectKnowledgeObjects(inputDir, include, exclude) {
|
|
|
246
248
|
};
|
|
247
249
|
if (typeof meta.guid === 'string')
|
|
248
250
|
obj.guid = meta.guid;
|
|
251
|
+
const tags = parseTags(meta.tags);
|
|
252
|
+
if (tags)
|
|
253
|
+
obj.tag = tags;
|
|
249
254
|
if (knowledgeType === 'skill') {
|
|
250
255
|
const skillFolder = posixPath.slice('skills/'.length, posixPath.indexOf('/SKILL.md'));
|
|
251
256
|
const refPrefix = `skills/${skillFolder}/references/`;
|
|
@@ -345,6 +350,7 @@ function canonical(obj) {
|
|
|
345
350
|
files,
|
|
346
351
|
mode: obj.mode ?? '',
|
|
347
352
|
scope: obj.scope ?? '',
|
|
353
|
+
tag: [...(obj.tag ?? [])].sort(),
|
|
348
354
|
});
|
|
349
355
|
}
|
|
350
356
|
/**
|
|
@@ -28,6 +28,16 @@ const REFERENCE_PATTERNS = [
|
|
|
28
28
|
},
|
|
29
29
|
// Schema foreign key references: table = "name" inside field definitions
|
|
30
30
|
{ keyword: 'table (FK)', regex: /\btable\s*=\s*"([^"]*)"/gm, targetType: 'table' },
|
|
31
|
+
// Realtime v2: a channel references its owning realtime_server by name via a
|
|
32
|
+
// top-level `realtime_server = "..."` (renamed from a bare `server` to match
|
|
33
|
+
// the api_group/mcp_server convention). The full keyword is unambiguous, so no
|
|
34
|
+
// false-match against another object type's `server` field.
|
|
35
|
+
{
|
|
36
|
+
keyword: 'channel (realtime_server)',
|
|
37
|
+
regex: /^\s*realtime_server\s*=\s*"([^"]*)"/gm,
|
|
38
|
+
sourceType: 'channel',
|
|
39
|
+
targetType: 'realtime_server',
|
|
40
|
+
},
|
|
31
41
|
];
|
|
32
42
|
/**
|
|
33
43
|
* Strip surrounding quotes from a name if present.
|
|
@@ -119,6 +129,9 @@ export function checkReferences(documents, serverOperations) {
|
|
|
119
129
|
if (!parsed)
|
|
120
130
|
continue;
|
|
121
131
|
for (const pattern of REFERENCE_PATTERNS) {
|
|
132
|
+
// Skip patterns scoped to a different source document type.
|
|
133
|
+
if (pattern.sourceType && pattern.sourceType !== parsed.type)
|
|
134
|
+
continue;
|
|
122
135
|
// Reset regex state for each document
|
|
123
136
|
pattern.regex.lastIndex = 0;
|
|
124
137
|
let match;
|