@xano/cli 1.0.6-beta.3 → 1.0.6-beta.6
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 +95 -4
- package/dist/commands/branch/list/index.js +5 -1
- package/dist/commands/ephemeral/list/index.js +2 -1
- package/dist/commands/ephemeral/pull/index.d.ts +0 -6
- package/dist/commands/ephemeral/pull/index.js +10 -95
- package/dist/commands/ephemeral/static_host/build/list/index.d.ts +2 -3
- package/dist/commands/ephemeral/static_host/build/list/index.js +19 -40
- package/dist/commands/ephemeral/static_host/list/index.d.ts +2 -3
- package/dist/commands/ephemeral/static_host/list/index.js +20 -40
- package/dist/commands/function/list/index.d.ts +4 -4
- package/dist/commands/function/list/index.js +30 -38
- package/dist/commands/knowledge/list/index.js +2 -1
- package/dist/commands/platform/list/index.js +13 -14
- package/dist/commands/release/list/index.js +13 -14
- package/dist/commands/release/pull/index.d.ts +0 -6
- package/dist/commands/release/pull/index.js +10 -95
- package/dist/commands/sandbox/env/list/index.js +4 -0
- package/dist/commands/sandbox/pull/index.d.ts +0 -1
- package/dist/commands/sandbox/pull/index.js +10 -77
- package/dist/commands/sandbox/unit_test/list/index.d.ts +2 -0
- package/dist/commands/sandbox/unit_test/list/index.js +9 -14
- package/dist/commands/sandbox/unit_test/run_all/index.d.ts +1 -0
- package/dist/commands/sandbox/unit_test/run_all/index.js +67 -37
- package/dist/commands/sandbox/workflow_test/list/index.d.ts +2 -0
- package/dist/commands/sandbox/workflow_test/list/index.js +9 -14
- package/dist/commands/sandbox/workflow_test/run_all/index.d.ts +1 -0
- package/dist/commands/sandbox/workflow_test/run_all/index.js +65 -35
- package/dist/commands/static_host/build/list/index.d.ts +2 -3
- package/dist/commands/static_host/build/list/index.js +19 -40
- package/dist/commands/static_host/build/pull/index.js +12 -9
- package/dist/commands/static_host/list/index.d.ts +2 -3
- package/dist/commands/static_host/list/index.js +20 -40
- package/dist/commands/static_host/migrate/index.js +14 -10
- package/dist/commands/tenant/backup/list/index.js +13 -14
- package/dist/commands/tenant/cluster/list/index.js +9 -10
- package/dist/commands/tenant/env/list/index.js +4 -0
- package/dist/commands/tenant/list/index.js +19 -20
- package/dist/commands/tenant/pull/index.d.ts +0 -6
- package/dist/commands/tenant/pull/index.js +7 -142
- package/dist/commands/tenant/snapshot/list/index.js +5 -1
- package/dist/commands/tenant/unit_test/list/index.d.ts +4 -2
- package/dist/commands/tenant/unit_test/list/index.js +9 -14
- package/dist/commands/tenant/unit_test/run_all/index.d.ts +1 -0
- package/dist/commands/tenant/unit_test/run_all/index.js +67 -37
- package/dist/commands/tenant/workflow_test/list/index.d.ts +4 -2
- package/dist/commands/tenant/workflow_test/list/index.js +9 -14
- package/dist/commands/tenant/workflow_test/run_all/index.d.ts +1 -0
- package/dist/commands/tenant/workflow_test/run_all/index.js +65 -35
- package/dist/commands/unit_test/list/index.d.ts +3 -1
- package/dist/commands/unit_test/list/index.js +9 -14
- package/dist/commands/unit_test/run_all/index.d.ts +1 -0
- package/dist/commands/unit_test/run_all/index.js +68 -40
- package/dist/commands/workflow_test/list/index.d.ts +3 -1
- package/dist/commands/workflow_test/list/index.js +10 -13
- package/dist/commands/workflow_test/run_all/index.d.ts +1 -0
- package/dist/commands/workflow_test/run_all/index.js +67 -36
- package/dist/commands/workspace/list/index.js +7 -13
- package/dist/commands/workspace/pull/index.d.ts +0 -6
- package/dist/commands/workspace/pull/index.js +7 -144
- package/dist/utils/concurrency.d.ts +29 -0
- package/dist/utils/concurrency.js +54 -0
- package/dist/utils/document-parser.d.ts +39 -0
- package/dist/utils/document-parser.js +139 -0
- package/dist/utils/paging.d.ts +141 -0
- package/dist/utils/paging.js +194 -0
- package/oclif.manifest.json +2950 -2824
- package/package.json +1 -1
|
@@ -3,9 +3,11 @@ export default class WorkflowTestList extends BaseCommand {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
per_page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
6
9
|
branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
10
|
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
-
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
11
|
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
12
|
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
13
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
import { buildPagingJson, buildPagingParams, formatPagingFooter, normalizeListResponse, pagingFlags } from '../../../utils/paging.js';
|
|
3
4
|
export default class WorkflowTestList extends BaseCommand {
|
|
4
5
|
static description = 'List all workflow tests in a workspace';
|
|
5
6
|
static examples = [
|
|
@@ -25,6 +26,7 @@ Workflow tests in workspace 5:
|
|
|
25
26
|
options: ['summary', 'json'],
|
|
26
27
|
required: false,
|
|
27
28
|
}),
|
|
29
|
+
...pagingFlags('envelope', { maxPerPage: 10_000 }),
|
|
28
30
|
workspace: Flags.string({
|
|
29
31
|
char: 'w',
|
|
30
32
|
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
@@ -38,7 +40,8 @@ Workflow tests in workspace 5:
|
|
|
38
40
|
if (!workspaceId) {
|
|
39
41
|
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
40
42
|
}
|
|
41
|
-
const params = new URLSearchParams(
|
|
43
|
+
const params = new URLSearchParams(buildPagingParams(flags, 'envelope'));
|
|
44
|
+
params.set('include_xanoscript', 'false');
|
|
42
45
|
if (flags.branch) {
|
|
43
46
|
params.set('branch', flags.branch);
|
|
44
47
|
}
|
|
@@ -55,19 +58,10 @@ Workflow tests in workspace 5:
|
|
|
55
58
|
const errorText = await response.text();
|
|
56
59
|
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
57
60
|
}
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
if (Array.isArray(data)) {
|
|
61
|
-
tests = data;
|
|
62
|
-
}
|
|
63
|
-
else if (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)) {
|
|
64
|
-
tests = data.items;
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
this.error('Unexpected API response format');
|
|
68
|
-
}
|
|
61
|
+
const list = normalizeListResponse(await response.json());
|
|
62
|
+
const tests = list.items;
|
|
69
63
|
if (flags.output === 'json') {
|
|
70
|
-
this.log(JSON.stringify(
|
|
64
|
+
this.log(JSON.stringify(buildPagingJson(list, { page: flags.page, perPage: flags.per_page, tier: 'envelope' }), null, 2));
|
|
71
65
|
}
|
|
72
66
|
else {
|
|
73
67
|
if (tests.length === 0) {
|
|
@@ -80,6 +74,9 @@ Workflow tests in workspace 5:
|
|
|
80
74
|
this.log(` - ${test.name} (ID: ${test.id})${desc}`);
|
|
81
75
|
}
|
|
82
76
|
}
|
|
77
|
+
const footer = formatPagingFooter(list, { noun: 'workflow test', page: flags.page, tier: 'envelope' });
|
|
78
|
+
if (footer)
|
|
79
|
+
this.log(footer);
|
|
83
80
|
}
|
|
84
81
|
}
|
|
85
82
|
catch (error) {
|
|
@@ -4,6 +4,7 @@ export default class WorkflowTestRunAll extends BaseCommand {
|
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
6
|
branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
concurrency: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
8
|
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
9
|
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
10
|
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
import { createOrderedEmitter, mapWithConcurrency } from '../../../utils/concurrency.js';
|
|
4
|
+
import { collectAllPages, INTERNAL_PAGE_SIZE, normalizeListResponse } from '../../../utils/paging.js';
|
|
3
5
|
export default class WorkflowTestRunAll extends BaseCommand {
|
|
4
6
|
static description = 'Run all workflow tests in a workspace';
|
|
5
7
|
static examples = [
|
|
@@ -22,6 +24,11 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
22
24
|
description: 'Filter by branch name',
|
|
23
25
|
required: false,
|
|
24
26
|
}),
|
|
27
|
+
concurrency: Flags.integer({
|
|
28
|
+
default: 1,
|
|
29
|
+
description: 'Run this many tests in parallel. Tests share the workspace database, so raise this only when your tests do not depend on shared state.',
|
|
30
|
+
required: false,
|
|
31
|
+
}),
|
|
25
32
|
output: Flags.string({
|
|
26
33
|
char: 'o',
|
|
27
34
|
default: 'summary',
|
|
@@ -45,32 +52,35 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
45
52
|
const baseUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/workflow_test`;
|
|
46
53
|
try {
|
|
47
54
|
// Step 1: List all workflow tests
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// Page the list rather than asking for everything at once: 100 per
|
|
56
|
+
// request is plenty for typical suites and does not make the instance
|
|
57
|
+
// build one enormous response. Termination is the server's own nextPage,
|
|
58
|
+
// never "the page came back full".
|
|
59
|
+
const branchValue = flags.branch;
|
|
60
|
+
const tests = await collectAllPages(async (page) => {
|
|
61
|
+
const listParams = new URLSearchParams();
|
|
62
|
+
listParams.set('page', String(page));
|
|
63
|
+
listParams.set('per_page', String(INTERNAL_PAGE_SIZE));
|
|
64
|
+
if (branchValue)
|
|
65
|
+
listParams.set('branch', branchValue);
|
|
66
|
+
listParams.set('include_xanoscript', 'false');
|
|
67
|
+
const listResponse = await this.verboseFetch(`${baseUrl}?${listParams}`, {
|
|
68
|
+
headers: {
|
|
69
|
+
accept: 'application/json',
|
|
70
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
71
|
+
},
|
|
72
|
+
method: 'GET',
|
|
73
|
+
}, flags.verbose, profile.access_token);
|
|
74
|
+
if (!listResponse.ok) {
|
|
75
|
+
const errorText = await listResponse.text();
|
|
76
|
+
this.error(`Failed to list workflow tests: ${listResponse.status}: ${listResponse.statusText}\n${errorText}`);
|
|
77
|
+
}
|
|
78
|
+
return normalizeListResponse(await listResponse.json());
|
|
79
|
+
}, {
|
|
80
|
+
onTruncate: (collected) => {
|
|
81
|
+
this.warn(`Stopped after ${collected} workflow tests — the list did not terminate. Some tests may not have run.`);
|
|
56
82
|
},
|
|
57
|
-
|
|
58
|
-
}, flags.verbose, profile.access_token);
|
|
59
|
-
if (!listResponse.ok) {
|
|
60
|
-
const errorText = await listResponse.text();
|
|
61
|
-
this.error(`Failed to list workflow tests: ${listResponse.status}: ${listResponse.statusText}\n${errorText}`);
|
|
62
|
-
}
|
|
63
|
-
const data = (await listResponse.json());
|
|
64
|
-
let tests;
|
|
65
|
-
if (Array.isArray(data)) {
|
|
66
|
-
tests = data;
|
|
67
|
-
}
|
|
68
|
-
else if (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)) {
|
|
69
|
-
tests = data.items;
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
this.error('Unexpected API response format');
|
|
73
|
-
}
|
|
83
|
+
});
|
|
74
84
|
if (tests.length === 0) {
|
|
75
85
|
this.log('No workflow tests found');
|
|
76
86
|
return;
|
|
@@ -81,7 +91,15 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
81
91
|
// Step 2: Run each test
|
|
82
92
|
const results = [];
|
|
83
93
|
let totalTiming = 0;
|
|
84
|
-
|
|
94
|
+
const runOne = async (test) => {
|
|
95
|
+
// Shadowed accumulators: the body below is the original sequential
|
|
96
|
+
// logic, but it records output instead of printing it so a concurrent
|
|
97
|
+
// run can still emit in input order.
|
|
98
|
+
const results = [];
|
|
99
|
+
const lines = [];
|
|
100
|
+
const log = (message) => {
|
|
101
|
+
lines.push(message);
|
|
102
|
+
};
|
|
85
103
|
const runUrl = `${baseUrl}/${test.id}/run`;
|
|
86
104
|
try {
|
|
87
105
|
const runResponse = await this.verboseFetch(runUrl, {
|
|
@@ -102,10 +120,10 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
102
120
|
};
|
|
103
121
|
results.push(result);
|
|
104
122
|
if (flags.output === 'summary') {
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
log(`FAIL ${test.name} (0.000s)`);
|
|
124
|
+
log(` Error: API error ${runResponse.status}`);
|
|
107
125
|
}
|
|
108
|
-
|
|
126
|
+
return { lines, results };
|
|
109
127
|
}
|
|
110
128
|
const runResult = (await runResponse.json());
|
|
111
129
|
const passed = runResult.status === 'ok';
|
|
@@ -120,12 +138,12 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
120
138
|
if (flags.output === 'summary') {
|
|
121
139
|
const timing = `(${runResult.timing.toFixed(3)}s)`;
|
|
122
140
|
if (passed) {
|
|
123
|
-
|
|
141
|
+
log(`PASS ${test.name} ${timing}`);
|
|
124
142
|
}
|
|
125
143
|
else {
|
|
126
|
-
|
|
144
|
+
log(`FAIL ${test.name} ${timing}`);
|
|
127
145
|
if (runResult.message) {
|
|
128
|
-
|
|
146
|
+
log(` Error: ${runResult.message}`);
|
|
129
147
|
}
|
|
130
148
|
}
|
|
131
149
|
}
|
|
@@ -139,16 +157,29 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
139
157
|
timing: 0,
|
|
140
158
|
});
|
|
141
159
|
if (flags.output === 'summary') {
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
log(`FAIL ${test.name} (0.000s)`);
|
|
161
|
+
log(` Error: ${message}`);
|
|
144
162
|
}
|
|
145
163
|
}
|
|
146
|
-
|
|
164
|
+
return { lines, results };
|
|
165
|
+
};
|
|
166
|
+
// Print in input order as each test settles, so concurrent output stays
|
|
167
|
+
// diffable against a sequential run.
|
|
168
|
+
const emitter = createOrderedEmitter((settled) => {
|
|
169
|
+
for (const line of settled.lines)
|
|
170
|
+
this.log(line);
|
|
171
|
+
});
|
|
172
|
+
const perTest = await mapWithConcurrency(tests, flags.concurrency, async (test, index) => {
|
|
173
|
+
const settled = await runOne(test);
|
|
174
|
+
emitter.settle(index, settled);
|
|
175
|
+
return settled;
|
|
176
|
+
});
|
|
177
|
+
results.push(...perTest.flatMap((r) => r.results));
|
|
147
178
|
// Step 3: Summary
|
|
148
179
|
const passed = results.filter((r) => r.status === 'pass').length;
|
|
149
180
|
const failed = results.filter((r) => r.status === 'fail').length;
|
|
150
181
|
if (flags.output === 'json') {
|
|
151
|
-
this.log(JSON.stringify({ passed,
|
|
182
|
+
this.log(JSON.stringify({ failed, passed, results, total_timing: totalTiming }, null, 2));
|
|
152
183
|
}
|
|
153
184
|
else {
|
|
154
185
|
this.log(`\nResults: ${passed} passed, ${failed} failed (${totalTiming.toFixed(3)}s total)`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
import { buildPagingJson, formatPagingFooter, normalizeListResponse } from '../../../utils/paging.js';
|
|
3
4
|
export default class WorkspaceList extends BaseCommand {
|
|
4
5
|
static description = 'List all workspaces from the Xano Metadata API';
|
|
5
6
|
static examples = [
|
|
@@ -71,24 +72,14 @@ Available workspaces:
|
|
|
71
72
|
const errorText = await response.text();
|
|
72
73
|
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
73
74
|
}
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
let workspaces;
|
|
77
|
-
if (Array.isArray(data)) {
|
|
78
|
-
workspaces = data;
|
|
79
|
-
}
|
|
80
|
-
else if (data && typeof data === 'object' && 'workspaces' in data && Array.isArray(data.workspaces)) {
|
|
81
|
-
workspaces = data.workspaces;
|
|
82
|
-
}
|
|
83
|
-
else {
|
|
84
|
-
this.error('Unexpected API response format');
|
|
85
|
-
}
|
|
75
|
+
const list = normalizeListResponse(await response.json(), ['workspaces']);
|
|
76
|
+
const workspaces = list.items;
|
|
86
77
|
if (flags.latest) {
|
|
87
78
|
workspaces.sort((a, b) => b.id - a.id);
|
|
88
79
|
}
|
|
89
80
|
// Output results
|
|
90
81
|
if (flags.output === 'json') {
|
|
91
|
-
this.log(JSON.stringify(
|
|
82
|
+
this.log(JSON.stringify(buildPagingJson(list, { tier: 'none' }), null, 2));
|
|
92
83
|
}
|
|
93
84
|
else {
|
|
94
85
|
// summary format
|
|
@@ -107,6 +98,9 @@ Available workspaces:
|
|
|
107
98
|
}
|
|
108
99
|
}
|
|
109
100
|
}
|
|
101
|
+
const footer = formatPagingFooter({ items: workspaces }, { noun: 'workspace', tier: 'none' });
|
|
102
|
+
if (footer)
|
|
103
|
+
this.log(footer);
|
|
110
104
|
}
|
|
111
105
|
}
|
|
112
106
|
catch (error) {
|
|
@@ -14,10 +14,4 @@ export default class Pull extends BaseCommand {
|
|
|
14
14
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
};
|
|
16
16
|
run(): Promise<void>;
|
|
17
|
-
/**
|
|
18
|
-
* Sanitize a document name for use as a filename.
|
|
19
|
-
* Strips quotes, replaces spaces with underscores, and removes
|
|
20
|
-
* characters that are unsafe in filenames.
|
|
21
|
-
*/
|
|
22
|
-
private sanitizeFilename;
|
|
23
17
|
}
|
|
@@ -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, buildChannelServerResolver,
|
|
6
|
+
import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } 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';
|
|
@@ -141,141 +141,12 @@ Pulled 58 documents
|
|
|
141
141
|
const filenameCounters = new Map();
|
|
142
142
|
let writtenCount = 0;
|
|
143
143
|
for (const doc of documents) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
else if (doc.type === 'workspace_trigger') {
|
|
152
|
-
// workspace_trigger → workspace/trigger/{name}.xs
|
|
153
|
-
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
154
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
155
|
-
}
|
|
156
|
-
else if (doc.type === 'error_trigger') {
|
|
157
|
-
// error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
|
|
158
|
-
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
159
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
160
|
-
}
|
|
161
|
-
else if (doc.type === 'agent') {
|
|
162
|
-
// agent → ai/agent/{name}.xs
|
|
163
|
-
typeDir = path.join(outputDir, 'ai', 'agent');
|
|
164
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
165
|
-
}
|
|
166
|
-
else if (doc.type === 'mcp_server') {
|
|
167
|
-
// mcp_server → ai/mcp_server/{name}.xs
|
|
168
|
-
typeDir = path.join(outputDir, 'ai', 'mcp_server');
|
|
169
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
170
|
-
}
|
|
171
|
-
else if (doc.type === 'tool') {
|
|
172
|
-
// tool → ai/tool/{name}.xs
|
|
173
|
-
typeDir = path.join(outputDir, 'ai', 'tool');
|
|
174
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
175
|
-
}
|
|
176
|
-
else if (doc.type === 'agent_trigger') {
|
|
177
|
-
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
178
|
-
typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
|
|
179
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
180
|
-
}
|
|
181
|
-
else if (doc.type === 'mcp_server_trigger') {
|
|
182
|
-
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
183
|
-
typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
|
|
184
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
185
|
-
}
|
|
186
|
-
else if (doc.type === 'table_trigger') {
|
|
187
|
-
// table_trigger → table/trigger/{name}.xs
|
|
188
|
-
typeDir = path.join(outputDir, 'table', 'trigger');
|
|
189
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
190
|
-
}
|
|
191
|
-
else if (doc.type === 'realtime_channel') {
|
|
192
|
-
// realtime_channel → realtime/channel/{name}.xs
|
|
193
|
-
typeDir = path.join(outputDir, 'realtime', 'channel');
|
|
194
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
195
|
-
}
|
|
196
|
-
else if (doc.type === 'realtime_trigger') {
|
|
197
|
-
// realtime_trigger → realtime/trigger/{name}.xs
|
|
198
|
-
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
199
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
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
|
-
}
|
|
250
|
-
else if (doc.type === 'api_group') {
|
|
251
|
-
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
252
|
-
const groupFolder = getApiGroupFolder(doc.name);
|
|
253
|
-
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
254
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
255
|
-
}
|
|
256
|
-
else if (doc.type === 'query' && doc.apiGroup) {
|
|
257
|
-
// query in group "test" → api/{resolved_folder}/{query_name}.xs
|
|
258
|
-
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
259
|
-
const nameParts = doc.name.split('/');
|
|
260
|
-
const leafName = nameParts.pop();
|
|
261
|
-
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
262
|
-
typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
|
|
263
|
-
baseName = this.sanitizeFilename(leafName);
|
|
264
|
-
if (doc.verb) {
|
|
265
|
-
baseName = `${baseName}_${doc.verb}`;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
else {
|
|
269
|
-
// Default: split folder path from name
|
|
270
|
-
const nameParts = doc.name.split('/');
|
|
271
|
-
const leafName = nameParts.pop();
|
|
272
|
-
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
273
|
-
typeDir = path.join(outputDir, doc.type, ...folderParts);
|
|
274
|
-
baseName = this.sanitizeFilename(leafName);
|
|
275
|
-
if (doc.verb) {
|
|
276
|
-
baseName = `${baseName}_${doc.verb}`;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
144
|
+
const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
|
|
145
|
+
getApiGroupFolder,
|
|
146
|
+
getChannelServer,
|
|
147
|
+
join: path.join,
|
|
148
|
+
snakeCase,
|
|
149
|
+
});
|
|
279
150
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
280
151
|
// Track duplicates per directory
|
|
281
152
|
const dirKey = path.relative(outputDir, typeDir);
|
|
@@ -304,12 +175,4 @@ Pulled 58 documents
|
|
|
304
175
|
parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
|
|
305
176
|
this.log(`Pulled ${parts.join(' + ')} to ${flags.directory}`);
|
|
306
177
|
}
|
|
307
|
-
/**
|
|
308
|
-
* Sanitize a document name for use as a filename.
|
|
309
|
-
* Strips quotes, replaces spaces with underscores, and removes
|
|
310
|
-
* characters that are unsafe in filenames.
|
|
311
|
-
*/
|
|
312
|
-
sanitizeFilename(name) {
|
|
313
|
-
return snakeCase(name.replaceAll('"', ''));
|
|
314
|
-
}
|
|
315
178
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `worker` over `items` with at most `limit` in flight at once.
|
|
3
|
+
*
|
|
4
|
+
* Results come back in **input order**, not completion order. That matters for
|
|
5
|
+
* test runners: a concurrent run must still produce output a human can diff
|
|
6
|
+
* against a sequential one, and a JSON results array whose order does not shift
|
|
7
|
+
* between runs.
|
|
8
|
+
*
|
|
9
|
+
* `limit` is clamped to at least 1, so a limit of 1 is exactly sequential
|
|
10
|
+
* execution and callers do not need a separate code path for it.
|
|
11
|
+
*
|
|
12
|
+
* The worker is expected to capture its own failures — one rejected worker
|
|
13
|
+
* rejects the whole call. Callers that want per-item error reporting (as the
|
|
14
|
+
* test runners do) should resolve a failure result instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function mapWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
17
|
+
/**
|
|
18
|
+
* Emit results in input order as soon as each becomes available, without
|
|
19
|
+
* waiting for the whole batch.
|
|
20
|
+
*
|
|
21
|
+
* Progress lines are the reason this exists: with concurrency the third test may
|
|
22
|
+
* finish before the first, but printing them out of order makes the output hard
|
|
23
|
+
* to follow and non-deterministic between runs. This buffers out-of-order
|
|
24
|
+
* completions and flushes the moment the next expected index arrives, so the
|
|
25
|
+
* user sees an ordered stream that still starts before the run ends.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createOrderedEmitter<R>(onReady: (result: R, index: number) => void): {
|
|
28
|
+
settle(index: number, result: R): void;
|
|
29
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `worker` over `items` with at most `limit` in flight at once.
|
|
3
|
+
*
|
|
4
|
+
* Results come back in **input order**, not completion order. That matters for
|
|
5
|
+
* test runners: a concurrent run must still produce output a human can diff
|
|
6
|
+
* against a sequential one, and a JSON results array whose order does not shift
|
|
7
|
+
* between runs.
|
|
8
|
+
*
|
|
9
|
+
* `limit` is clamped to at least 1, so a limit of 1 is exactly sequential
|
|
10
|
+
* execution and callers do not need a separate code path for it.
|
|
11
|
+
*
|
|
12
|
+
* The worker is expected to capture its own failures — one rejected worker
|
|
13
|
+
* rejects the whole call. Callers that want per-item error reporting (as the
|
|
14
|
+
* test runners do) should resolve a failure result instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
export async function mapWithConcurrency(items, limit, worker) {
|
|
17
|
+
const effectiveLimit = Math.max(1, Math.floor(limit));
|
|
18
|
+
const results = Array.from({ length: items.length });
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
const runners = Array.from({ length: Math.min(effectiveLimit, items.length) }, async () => {
|
|
21
|
+
while (cursor < items.length) {
|
|
22
|
+
const index = cursor;
|
|
23
|
+
cursor++;
|
|
24
|
+
// eslint-disable-next-line no-await-in-loop
|
|
25
|
+
results[index] = await worker(items[index], index);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
await Promise.all(runners);
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Emit results in input order as soon as each becomes available, without
|
|
33
|
+
* waiting for the whole batch.
|
|
34
|
+
*
|
|
35
|
+
* Progress lines are the reason this exists: with concurrency the third test may
|
|
36
|
+
* finish before the first, but printing them out of order makes the output hard
|
|
37
|
+
* to follow and non-deterministic between runs. This buffers out-of-order
|
|
38
|
+
* completions and flushes the moment the next expected index arrives, so the
|
|
39
|
+
* user sees an ordered stream that still starts before the run ends.
|
|
40
|
+
*/
|
|
41
|
+
export function createOrderedEmitter(onReady) {
|
|
42
|
+
const pending = new Map();
|
|
43
|
+
let next = 0;
|
|
44
|
+
return {
|
|
45
|
+
settle(index, result) {
|
|
46
|
+
pending.set(index, result);
|
|
47
|
+
while (pending.has(next)) {
|
|
48
|
+
onReady(pending.get(next), next);
|
|
49
|
+
pending.delete(next);
|
|
50
|
+
next++;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -58,6 +58,45 @@ export declare function channelPathSegments(name: string, snakeCaseFn: (s: strin
|
|
|
58
58
|
* callers fall back accordingly.
|
|
59
59
|
*/
|
|
60
60
|
export declare function buildChannelServerResolver(documents: ParsedDocument[]): (channelName: string) => string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Sanitize a document name for use as a single on-disk filename segment.
|
|
63
|
+
* Strips quotes and snake_cases the rest. Mirrors the per-command
|
|
64
|
+
* `sanitizeFilename` the pull commands used to each define privately.
|
|
65
|
+
*/
|
|
66
|
+
export declare function sanitizeDocumentName(name: string, snakeCaseFn: (s: string) => string): string;
|
|
67
|
+
/**
|
|
68
|
+
* The directory + base filename (no extension, no duplicate suffix) a document
|
|
69
|
+
* is written to on disk. `resolveDocumentPath` returns this; callers append the
|
|
70
|
+
* `.xs` extension and any `_N` duplicate suffix.
|
|
71
|
+
*/
|
|
72
|
+
export interface DocumentPlacement {
|
|
73
|
+
baseName: string;
|
|
74
|
+
typeDir: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve where a parsed document lands on disk — the SINGLE source of truth for
|
|
78
|
+
* the pull layout, shared by every pull command (workspace, tenant, release,
|
|
79
|
+
* sandbox, ephemeral). Previously each command inlined a ~130-line copy of this
|
|
80
|
+
* decision; the copies drifted (the realtime v2 realtime_server/channel/message
|
|
81
|
+
* branches existed only in workspace/tenant), so an ephemeral pull dumped v2
|
|
82
|
+
* objects into flat top-level channel/, message/ and realtime_server/ folders —
|
|
83
|
+
* detaching messages from their channels and risking name collisions (message
|
|
84
|
+
* names are unique only WITHIN a channel).
|
|
85
|
+
*
|
|
86
|
+
* @param doc - the parsed document
|
|
87
|
+
* @param outputDir - the resolved pull output root
|
|
88
|
+
* @param deps - injected dependencies
|
|
89
|
+
* @param deps.getApiGroupFolder - resolves an api_group name to its unique folder
|
|
90
|
+
* @param deps.getChannelServer - resolves a v2 channel path to its owning realtime_server
|
|
91
|
+
* @param deps.join - the path.join implementation (injectable for testing)
|
|
92
|
+
* @param deps.snakeCase - the snakeCase implementation the caller uses
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolveDocumentPath(doc: ParsedDocument, outputDir: string, deps: {
|
|
95
|
+
getApiGroupFolder: (groupName: string) => string;
|
|
96
|
+
getChannelServer: (channelName: string) => string | undefined;
|
|
97
|
+
join: (...parts: string[]) => string;
|
|
98
|
+
snakeCase: (s: string) => string;
|
|
99
|
+
}): DocumentPlacement;
|
|
61
100
|
/**
|
|
62
101
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
63
102
|
* Used to match server GUID map entries back to local files.
|