@xano/cli 1.0.6-beta.0 → 1.0.6-beta.2

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 (36) hide show
  1. package/README.md +29 -3
  2. package/dist/base-command.d.ts +2 -0
  3. package/dist/base-command.js +10 -4
  4. package/dist/commands/ephemeral/impersonate/index.d.ts +1 -0
  5. package/dist/commands/ephemeral/impersonate/index.js +11 -3
  6. package/dist/commands/ephemeral/pull/index.d.ts +3 -1
  7. package/dist/commands/ephemeral/pull/index.js +15 -14
  8. package/dist/commands/ephemeral/push/index.d.ts +3 -1
  9. package/dist/commands/ephemeral/push/index.js +17 -16
  10. package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +20 -0
  11. package/dist/commands/ephemeral/static_host/build/delete/index.js +121 -0
  12. package/dist/commands/ephemeral/static_host/build/get/index.d.ts +18 -0
  13. package/dist/commands/ephemeral/static_host/build/get/index.js +137 -0
  14. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +19 -0
  15. package/dist/commands/ephemeral/static_host/build/list/index.js +158 -0
  16. package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +53 -0
  17. package/dist/commands/ephemeral/static_host/build/pull/index.js +308 -0
  18. package/dist/commands/ephemeral/static_host/build/push/index.d.ts +24 -0
  19. package/dist/commands/ephemeral/static_host/build/push/index.js +250 -0
  20. package/dist/commands/ephemeral/static_host/create/index.d.ts +18 -0
  21. package/dist/commands/ephemeral/static_host/create/index.js +92 -0
  22. package/dist/commands/ephemeral/static_host/deploy/index.d.ts +19 -0
  23. package/dist/commands/ephemeral/static_host/deploy/index.js +112 -0
  24. package/dist/commands/ephemeral/static_host/edit/index.d.ts +24 -0
  25. package/dist/commands/ephemeral/static_host/edit/index.js +158 -0
  26. package/dist/commands/ephemeral/static_host/get/index.d.ts +19 -0
  27. package/dist/commands/ephemeral/static_host/get/index.js +101 -0
  28. package/dist/commands/ephemeral/static_host/list/index.d.ts +18 -0
  29. package/dist/commands/ephemeral/static_host/list/index.js +152 -0
  30. package/dist/commands/tenant/pull/index.js +12 -1
  31. package/dist/commands/workspace/git/pull/index.js +10 -1
  32. package/dist/commands/workspace/pull/index.js +21 -1
  33. package/dist/utils/document-parser.d.ts +26 -0
  34. package/dist/utils/document-parser.js +38 -1
  35. package/oclif.manifest.json +3832 -2632
  36. package/package.json +7 -1
@@ -0,0 +1,19 @@
1
+ import BaseCommand from '../../../../base-command.js';
2
+ export default class EphemeralStaticHostGet extends BaseCommand {
3
+ static args: {
4
+ tenant_name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ 'static-host': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ };
16
+ run(): Promise<void>;
17
+ /** Print a one-line summary for a dev/prod env if it has been deployed. */
18
+ private logEnv;
19
+ }
@@ -0,0 +1,101 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import BaseCommand from '../../../../base-command.js';
3
+ export default class EphemeralStaticHostGet extends BaseCommand {
4
+ static args = {
5
+ tenant_name: Args.string({
6
+ description: 'Ephemeral tenant name',
7
+ required: true,
8
+ }),
9
+ };
10
+ static description = 'Get a single static host\'s details (name, git config, dev/prod environments) for an ephemeral tenant';
11
+ static examples = [
12
+ `$ xano ephemeral static_host get e4f2-9ab1-xyz1 --static-host newsite
13
+ Static Host: newsite
14
+ ID: 5
15
+ Dev: https://newsite-dev-....dev.xano.io (v2)
16
+ `,
17
+ `$ xano ephemeral static_host get e4f2-9ab1-xyz1 -H newsite -w 40 -o json`,
18
+ ];
19
+ static flags = {
20
+ ...BaseCommand.baseFlags,
21
+ output: Flags.string({
22
+ char: 'o',
23
+ default: 'summary',
24
+ description: 'Output format',
25
+ options: ['summary', 'json'],
26
+ required: false,
27
+ }),
28
+ 'static-host': Flags.string({
29
+ char: 'H',
30
+ description: 'Static host name',
31
+ required: true,
32
+ }),
33
+ workspace: Flags.string({
34
+ char: 'w',
35
+ description: 'Workspace ID (optional if set in profile)',
36
+ required: false,
37
+ }),
38
+ };
39
+ async run() {
40
+ const { args, flags } = await this.parse(EphemeralStaticHostGet);
41
+ const { profile, profileName } = this.resolveProfile(flags);
42
+ const tenantName = args.tenant_name;
43
+ let workspaceId;
44
+ if (flags.workspace) {
45
+ workspaceId = flags.workspace;
46
+ }
47
+ else if (profile.workspace) {
48
+ workspaceId = profile.workspace;
49
+ }
50
+ else {
51
+ this.error(`Workspace ID is required. Either:\n` +
52
+ ` 1. Provide it as a flag: xano ephemeral static_host get <tenant_name> --static-host <static_host> -w <workspace_id>\n` +
53
+ ` 2. Set it in your profile using: xano profile edit ${profileName} -w <workspace_id>`);
54
+ }
55
+ const staticHost = flags['static-host'];
56
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/static_host/${staticHost}`;
57
+ try {
58
+ const response = await this.verboseFetch(apiUrl, {
59
+ headers: {
60
+ accept: 'application/json',
61
+ Authorization: `Bearer ${profile.access_token}`,
62
+ },
63
+ method: 'GET',
64
+ }, flags.verbose, profile.access_token);
65
+ if (!response.ok) {
66
+ const message = await this.parseApiError(response, `Failed to get static host '${staticHost}'`);
67
+ this.error(message);
68
+ }
69
+ const host = (await response.json());
70
+ if (flags.output === 'json') {
71
+ this.log(JSON.stringify(host, null, 2));
72
+ }
73
+ else {
74
+ this.log(`Static Host: ${host.name}`);
75
+ this.log(`ID: ${host.id}`);
76
+ if (host.description)
77
+ this.log(`Description: ${host.description}`);
78
+ if (host.git?.repo)
79
+ this.log(`Git: ${host.git.repo}`);
80
+ this.logEnv('Dev', host.dev);
81
+ this.logEnv('Prod', host.prod);
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (error instanceof Error) {
86
+ this.error(`Failed to get static host: ${error.message}`);
87
+ }
88
+ else {
89
+ this.error(`Failed to get static host: ${String(error)}`);
90
+ }
91
+ }
92
+ }
93
+ /** Print a one-line summary for a dev/prod env if it has been deployed. */
94
+ logEnv(label, env) {
95
+ if (!env?.default_url)
96
+ return;
97
+ const modeInfo = env.mode ? ` (${env.mode})` : '';
98
+ const custom = env.custom_url ? ` [custom: ${env.custom_url}]` : '';
99
+ this.log(`${label}: ${env.default_url}${modeInfo}${custom}`);
100
+ }
101
+ }
@@ -0,0 +1,18 @@
1
+ import BaseCommand from '../../../../base-command.js';
2
+ export default class EphemeralStaticHostList extends BaseCommand {
3
+ static args: {
4
+ tenant_name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
11
+ per_page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
12
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ };
17
+ run(): Promise<void>;
18
+ }
@@ -0,0 +1,152 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import BaseCommand from '../../../../base-command.js';
3
+ export default class EphemeralStaticHostList extends BaseCommand {
4
+ static args = {
5
+ tenant_name: Args.string({
6
+ description: 'Ephemeral tenant name',
7
+ required: true,
8
+ }),
9
+ };
10
+ static description = 'List all static hosts for an ephemeral tenant from the Xano Metadata API';
11
+ static examples = [
12
+ `$ xano ephemeral static_host list e4f2-9ab1-xyz1 -w 40
13
+ Available static hosts:
14
+ - my-static-host (ID: 1)
15
+ - another-host (ID: 2)
16
+ `,
17
+ `$ xano ephemeral static_host list e4f2-9ab1-xyz1 --profile production
18
+ Available static hosts:
19
+ - my-static-host (ID: 1)
20
+ - another-host (ID: 2)
21
+ `,
22
+ `$ xano ephemeral static_host list e4f2-9ab1-xyz1 -w 40 --output json
23
+ [
24
+ {
25
+ "id": 1,
26
+ "name": "my-static-host",
27
+ "domain": "example.com"
28
+ }
29
+ ]
30
+ `,
31
+ `$ xano ephemeral static_host list e4f2-9ab1-xyz1 -p staging -o json --page 2
32
+ [
33
+ {
34
+ "id": 3,
35
+ "name": "static-host-3"
36
+ }
37
+ ]
38
+ `,
39
+ ];
40
+ static flags = {
41
+ ...BaseCommand.baseFlags,
42
+ output: Flags.string({
43
+ char: 'o',
44
+ default: 'summary',
45
+ description: 'Output format',
46
+ options: ['summary', 'json'],
47
+ required: false,
48
+ }),
49
+ page: Flags.integer({
50
+ default: 1,
51
+ description: 'Page number for pagination',
52
+ required: false,
53
+ }),
54
+ per_page: Flags.integer({
55
+ default: 50,
56
+ description: 'Number of results per page',
57
+ required: false,
58
+ }),
59
+ workspace: Flags.string({
60
+ char: 'w',
61
+ description: 'Workspace ID (optional if set in profile)',
62
+ required: false,
63
+ }),
64
+ };
65
+ async run() {
66
+ const { args, flags } = await this.parse(EphemeralStaticHostList);
67
+ const { profile, profileName } = this.resolveProfile(flags);
68
+ const tenantName = args.tenant_name;
69
+ // Determine workspace_id from flag or profile
70
+ let workspaceId;
71
+ if (flags.workspace) {
72
+ workspaceId = flags.workspace;
73
+ }
74
+ else if (profile.workspace) {
75
+ workspaceId = profile.workspace;
76
+ }
77
+ else {
78
+ this.error(`Workspace ID is required. Either:\n` +
79
+ ` 1. Provide it as a flag: xano ephemeral static_host list <tenant_name> -w <workspace_id>\n` +
80
+ ` 2. Set it in your profile using: xano profile:edit ${profileName} -w <workspace_id>`);
81
+ }
82
+ // Build query parameters
83
+ const queryParams = new URLSearchParams({
84
+ page: flags.page.toString(),
85
+ });
86
+ // Only add per_page if it's not the default value
87
+ if (flags.per_page !== 50) {
88
+ queryParams.append('per_page', flags.per_page.toString());
89
+ }
90
+ // Construct the API URL
91
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/static_host?${queryParams.toString()}`;
92
+ // Fetch static hosts from the API
93
+ try {
94
+ const response = await this.verboseFetch(apiUrl, {
95
+ headers: {
96
+ 'accept': 'application/json',
97
+ 'Authorization': `Bearer ${profile.access_token}`,
98
+ },
99
+ method: 'GET',
100
+ }, flags.verbose, profile.access_token);
101
+ if (!response.ok) {
102
+ const errorText = await response.text();
103
+ this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
104
+ }
105
+ const data = await response.json();
106
+ // Handle different response formats
107
+ let staticHosts;
108
+ if (Array.isArray(data)) {
109
+ staticHosts = data;
110
+ }
111
+ else if (data && typeof data === 'object' && 'static_hosts' in data && Array.isArray(data.static_hosts)) {
112
+ staticHosts = data.static_hosts;
113
+ }
114
+ else if (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)) {
115
+ staticHosts = data.items;
116
+ }
117
+ else {
118
+ this.error('Unexpected API response format');
119
+ }
120
+ // Output results
121
+ if (flags.output === 'json') {
122
+ this.log(JSON.stringify(staticHosts, null, 2));
123
+ }
124
+ else {
125
+ // summary format
126
+ if (staticHosts.length === 0) {
127
+ this.log('No static hosts found');
128
+ }
129
+ else {
130
+ this.log('Available static hosts:');
131
+ for (const host of staticHosts) {
132
+ if (host.id === undefined) {
133
+ this.log(` - ${host.name}`);
134
+ }
135
+ else {
136
+ const domainInfo = host.domain ? ` - ${host.domain}` : '';
137
+ this.log(` - ${host.name} (ID: ${host.id})${domainInfo}`);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ catch (error) {
144
+ if (error instanceof Error) {
145
+ this.error(`Failed to fetch static hosts: ${error.message}`);
146
+ }
147
+ else {
148
+ this.error(`Failed to fetch static hosts: ${String(error)}`);
149
+ }
150
+ }
151
+ }
152
+ }
@@ -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, 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 = [
@@ -190,6 +190,17 @@ Pulled 58 documents from tenant my-tenant
190
190
  typeDir = path.join(outputDir, 'realtime', 'trigger');
191
191
  baseName = this.sanitizeFilename(doc.name);
192
192
  }
193
+ else if (doc.type === 'channel') {
194
+ // Realtime v2 — see workspace/pull for the full rationale.
195
+ // channel "rooms/{room_id}" → channel/rooms/[room_id]/_channel.xs
196
+ typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
197
+ baseName = '_channel';
198
+ }
199
+ else if (doc.type === 'message' && doc.channel) {
200
+ // Realtime v2 message → channel/{channel_path}/{message_name}.xs
201
+ typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
202
+ baseName = this.sanitizeFilename(doc.name);
203
+ }
193
204
  else if (doc.type === 'api_group') {
194
205
  // api_group "test" → api/{resolved_folder}/{name}.xs
195
206
  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, 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 = [
@@ -373,6 +373,15 @@ export default class GitPull extends BaseCommand {
373
373
  typeDir = path.join(outputDir, 'realtime', 'trigger');
374
374
  baseName = this.sanitizeFilename(doc.name);
375
375
  }
376
+ else if (doc.type === 'channel') {
377
+ // Realtime v2 — see workspace/pull for the full rationale.
378
+ typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
379
+ baseName = '_channel';
380
+ }
381
+ else if (doc.type === 'message' && doc.channel) {
382
+ typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
383
+ baseName = this.sanitizeFilename(doc.name);
384
+ }
376
385
  else if (doc.type === 'api_group') {
377
386
  const groupFolder = getApiGroupFolder(doc.name);
378
387
  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, 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';
@@ -195,6 +195,26 @@ Pulled 58 documents
195
195
  typeDir = path.join(outputDir, 'realtime', 'trigger');
196
196
  baseName = this.sanitizeFilename(doc.name);
197
197
  }
198
+ else if (doc.type === 'channel') {
199
+ // Realtime v2. The channel owns a directory mirroring its path and its
200
+ // messages live inside it, so ownership is visible in the tree.
201
+ // channel "rooms/{room_id}" → channel/rooms/[room_id]/_channel.xs
202
+ //
203
+ // The document uses a FIXED "_channel.xs" leaf: naming it after the
204
+ // last segment yields "[room_id].xs", which names the parameter rather
205
+ // than the channel. A fixed leaf also sorts first, making "the channel
206
+ // vs its messages" obvious.
207
+ typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
208
+ baseName = '_channel';
209
+ }
210
+ else if (doc.type === 'message' && doc.channel) {
211
+ // Realtime v2 message → channel/{channel_path}/{message_name}.xs
212
+ // Nesting matters beyond tidiness: message names are unique only WITHIN
213
+ // a channel, so a flat message/ directory would collide when two
214
+ // channels both define e.g. "say".
215
+ typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
216
+ baseName = this.sanitizeFilename(doc.name);
217
+ }
198
218
  else if (doc.type === 'api_group') {
199
219
  // api_group "test" → api/{resolved_folder}/{name}.xs
200
220
  const groupFolder = getApiGroupFolder(doc.name);
@@ -1,6 +1,8 @@
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;
@@ -12,6 +14,30 @@ export interface ParsedDocument {
12
14
  * Skips leading comment lines (starting with //) to find the first meaningful line.
13
15
  */
14
16
  export declare function parseDocument(content: string): null | ParsedDocument;
17
+ /**
18
+ * Map a realtime v2 channel path onto directory segments.
19
+ *
20
+ * A channel name IS a path template — "rooms/{room_id}" — so the directory tree
21
+ * mirrors it rather than flattening it. That is the whole reason messages nest
22
+ * under channels: the tree should show the structure, and
23
+ * "channel/rooms/[room_id]/post.xs" says immediately that `post` lives on a
24
+ * parameterized rooms channel.
25
+ *
26
+ * "rooms" -> ["rooms"]
27
+ * "rooms/{room_id}" -> ["rooms", "[room_id]"]
28
+ * "org/{org_id}/room/{id}" -> ["org", "[org_id]", "room", "[id]"]
29
+ *
30
+ * Braces become brackets because "{" and "}" are awkward in shells, while "["
31
+ * and "]" are legal on every filesystem. They are still glob character classes,
32
+ * so scripting against a checkout needs the path quoted.
33
+ *
34
+ * Escaping the braces rather than dropping them is what keeps a parameterized
35
+ * channel distinct from a literal one: without it, "rooms/{room_id}" and a
36
+ * literal channel named "rooms/room_id" collide on the same directory.
37
+ *
38
+ * Mirrors Vfs::channelSegment() on the server.
39
+ */
40
+ export declare function channelPathSegments(name: string, snakeCaseFn: (s: string) => string): string[];
15
41
  /**
16
42
  * Build a unique key for a document based on its type, name, verb, and api_group.
17
43
  * Used to match server GUID map entries back to local files.
@@ -45,6 +45,14 @@ 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
+ }
48
56
  // Extract canonical if present (e.g., canonical = "abc123")
49
57
  let canonical;
50
58
  const canonicalMatch = content.match(/canonical\s*=\s*"([^"]*)"/);
@@ -57,7 +65,36 @@ export function parseDocument(content) {
57
65
  if (guidMatch) {
58
66
  guid = guidMatch[1];
59
67
  }
60
- return { apiGroup, canonical, content, guid, name, type, verb };
68
+ return { apiGroup, canonical, channel, content, guid, name, type, verb };
69
+ }
70
+ /**
71
+ * Map a realtime v2 channel path onto directory segments.
72
+ *
73
+ * A channel name IS a path template — "rooms/{room_id}" — so the directory tree
74
+ * mirrors it rather than flattening it. That is the whole reason messages nest
75
+ * under channels: the tree should show the structure, and
76
+ * "channel/rooms/[room_id]/post.xs" says immediately that `post` lives on a
77
+ * parameterized rooms channel.
78
+ *
79
+ * "rooms" -> ["rooms"]
80
+ * "rooms/{room_id}" -> ["rooms", "[room_id]"]
81
+ * "org/{org_id}/room/{id}" -> ["org", "[org_id]", "room", "[id]"]
82
+ *
83
+ * Braces become brackets because "{" and "}" are awkward in shells, while "["
84
+ * and "]" are legal on every filesystem. They are still glob character classes,
85
+ * so scripting against a checkout needs the path quoted.
86
+ *
87
+ * Escaping the braces rather than dropping them is what keeps a parameterized
88
+ * channel distinct from a literal one: without it, "rooms/{room_id}" and a
89
+ * literal channel named "rooms/room_id" collide on the same directory.
90
+ *
91
+ * Mirrors Vfs::channelSegment() on the server.
92
+ */
93
+ export function channelPathSegments(name, snakeCaseFn) {
94
+ // A parameter keeps the author's name verbatim so it matches the `input`
95
+ // block; only the delimiters are swapped.
96
+ const isParam = /^\{.*\}$/;
97
+ return name.split('/').map((segment) => (isParam.test(segment) ? `[${segment.slice(1, -1)}]` : snakeCaseFn(segment)));
61
98
  }
62
99
  /**
63
100
  * Build a unique key for a document based on its type, name, verb, and api_group.