@xano/cli 0.0.43 → 0.0.45
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
CHANGED
|
@@ -112,6 +112,16 @@ xano workspace push ./my-workspace --no-records # Schema only
|
|
|
112
112
|
xano workspace push ./my-workspace --no-env # Skip env vars
|
|
113
113
|
xano workspace push ./my-workspace --truncate # Truncate tables before import
|
|
114
114
|
xano workspace push ./my-workspace --no-sync-guids # Skip writing GUIDs back to local files
|
|
115
|
+
|
|
116
|
+
# Pull from a git repository to local files
|
|
117
|
+
xano workspace git pull ./output -r https://github.com/owner/repo
|
|
118
|
+
xano workspace git pull ./output -r https://github.com/owner/repo -b main
|
|
119
|
+
xano workspace git pull ./output -r https://github.com/owner/repo/tree/main/path/to/dir
|
|
120
|
+
xano workspace git pull ./output -r https://github.com/owner/repo/blob/main/file.xs
|
|
121
|
+
xano workspace git pull ./output -r git@github.com:owner/repo.git
|
|
122
|
+
xano workspace git pull ./output -r https://gitlab.com/owner/repo/-/tree/master/path
|
|
123
|
+
xano workspace git pull ./output -r https://github.com/owner/private-repo -t ghp_xxx
|
|
124
|
+
xano workspace git pull ./output -r https://github.com/owner/repo --path subdir
|
|
115
125
|
```
|
|
116
126
|
|
|
117
127
|
### Branches
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import BaseCommand from '../../base-command.js';
|
|
3
|
+
export default class Update extends BaseCommand {
|
|
4
|
+
static description = 'Update the Xano CLI to the latest version';
|
|
5
|
+
static examples = [
|
|
6
|
+
`$ xano update`,
|
|
7
|
+
];
|
|
8
|
+
async run() {
|
|
9
|
+
const currentVersion = this.config.version;
|
|
10
|
+
this.log(`Current version: ${currentVersion}`);
|
|
11
|
+
this.log('Checking for updates...');
|
|
12
|
+
try {
|
|
13
|
+
const latest = execSync('npm view @xano/cli version', { encoding: 'utf8' }).trim();
|
|
14
|
+
if (latest === currentVersion) {
|
|
15
|
+
this.log(`Already up to date (${currentVersion})`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
this.log(`Updating @xano/cli ${currentVersion} → ${latest}...`);
|
|
19
|
+
execSync('npm install -g @xano/cli@latest', { stdio: 'inherit' });
|
|
20
|
+
this.log(`Updated to ${latest}`);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
this.error(`Failed to update: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import BaseCommand from '../../../../base-command.js';
|
|
2
|
+
export default class GitPull extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
directory: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
path: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
repo: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
token: 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
|
+
/**
|
|
18
|
+
* Recursively collect all .xs files from a directory, sorted for deterministic ordering.
|
|
19
|
+
*/
|
|
20
|
+
private collectFiles;
|
|
21
|
+
/**
|
|
22
|
+
* Clone a git repository using shallow clone.
|
|
23
|
+
*/
|
|
24
|
+
private cloneRepo;
|
|
25
|
+
/**
|
|
26
|
+
* Fetch a GitHub repository via the tarball API for fast download without requiring git.
|
|
27
|
+
*/
|
|
28
|
+
private fetchGitHubTarball;
|
|
29
|
+
/**
|
|
30
|
+
* Parse a repository URL to extract host, owner, repo, ref, and path.
|
|
31
|
+
* Supports various URL formats:
|
|
32
|
+
*
|
|
33
|
+
* GitHub:
|
|
34
|
+
* https://github.com/owner/repo
|
|
35
|
+
* https://github.com/owner/repo.git
|
|
36
|
+
* git@github.com:owner/repo.git
|
|
37
|
+
* https://github.com/owner/repo/tree/main/path/to/dir
|
|
38
|
+
* https://github.com/owner/repo/blob/main/path/to/file.xs
|
|
39
|
+
* https://raw.githubusercontent.com/owner/repo/refs/heads/main/path/to/file.xs
|
|
40
|
+
*
|
|
41
|
+
* GitLab:
|
|
42
|
+
* https://gitlab.com/owner/repo
|
|
43
|
+
* https://gitlab.com/owner/repo/-/tree/master/path/to/dir
|
|
44
|
+
* https://gitlab.com/owner/repo/-/blob/master/path/to/file
|
|
45
|
+
*
|
|
46
|
+
* Other git URLs passed through for git clone.
|
|
47
|
+
*/
|
|
48
|
+
private parseRepoUrl;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the output directory and base filename for a parsed document.
|
|
51
|
+
* Uses the same type-to-directory mapping as workspace pull.
|
|
52
|
+
*/
|
|
53
|
+
private resolveOutputPath;
|
|
54
|
+
/**
|
|
55
|
+
* Sanitize a document name for use as a filename.
|
|
56
|
+
*/
|
|
57
|
+
private sanitizeFilename;
|
|
58
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import snakeCase from 'lodash.snakecase';
|
|
7
|
+
import BaseCommand from '../../../../base-command.js';
|
|
8
|
+
import { parseDocument } from '../../../../utils/document-parser.js';
|
|
9
|
+
export default class GitPull extends BaseCommand {
|
|
10
|
+
static args = {
|
|
11
|
+
directory: Args.string({
|
|
12
|
+
description: 'Output directory for imported files',
|
|
13
|
+
required: true,
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
static description = 'Pull XanoScript files from a git repository into a local directory';
|
|
17
|
+
static examples = [
|
|
18
|
+
`$ xano workspace git pull ./output -r https://github.com/owner/repo`,
|
|
19
|
+
`$ xano workspace git pull ./output -r https://github.com/owner/repo/tree/main/path/to/dir`,
|
|
20
|
+
`$ xano workspace git pull ./output -r https://github.com/owner/repo/blob/main/path/to/file.xs`,
|
|
21
|
+
`$ xano workspace git pull ./output -r git@github.com:owner/repo.git`,
|
|
22
|
+
`$ xano workspace git pull ./output -r https://github.com/owner/private-repo -t ghp_xxx`,
|
|
23
|
+
`$ xano workspace git pull ./output -r https://gitlab.com/owner/repo/-/tree/master/path`,
|
|
24
|
+
`$ xano workspace git pull ./output -r https://gitlab.com/owner/repo -b main`,
|
|
25
|
+
];
|
|
26
|
+
static flags = {
|
|
27
|
+
...BaseCommand.baseFlags,
|
|
28
|
+
branch: Flags.string({
|
|
29
|
+
char: 'b',
|
|
30
|
+
description: 'Branch, tag, or ref to fetch (defaults to repository default branch)',
|
|
31
|
+
required: false,
|
|
32
|
+
}),
|
|
33
|
+
path: Flags.string({
|
|
34
|
+
description: 'Subdirectory within the repo to import from',
|
|
35
|
+
required: false,
|
|
36
|
+
}),
|
|
37
|
+
repo: Flags.string({
|
|
38
|
+
char: 'r',
|
|
39
|
+
description: 'Git repository URL (GitHub HTTPS, SSH, or any git URL)',
|
|
40
|
+
required: true,
|
|
41
|
+
}),
|
|
42
|
+
token: Flags.string({
|
|
43
|
+
char: 't',
|
|
44
|
+
description: 'Personal access token for private repos (falls back to GITHUB_TOKEN env var)',
|
|
45
|
+
env: 'GITHUB_TOKEN',
|
|
46
|
+
required: false,
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
async run() {
|
|
50
|
+
const { args, flags } = await this.parse(GitPull);
|
|
51
|
+
const token = flags.token || '';
|
|
52
|
+
const outputDir = path.resolve(args.directory);
|
|
53
|
+
// Normalize the URL to extract owner/repo/ref/path from various formats
|
|
54
|
+
const repoInfo = this.parseRepoUrl(flags.repo);
|
|
55
|
+
// CLI flags override values extracted from the URL
|
|
56
|
+
const ref = flags.branch || repoInfo.ref;
|
|
57
|
+
const subPath = flags.path || repoInfo.pathInRepo;
|
|
58
|
+
// Create a temp directory for fetching
|
|
59
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xano-git-pull-'));
|
|
60
|
+
try {
|
|
61
|
+
// Fetch repository contents
|
|
62
|
+
let repoRoot;
|
|
63
|
+
if (repoInfo.host === 'github') {
|
|
64
|
+
repoRoot = await this.fetchGitHubTarball(repoInfo.owner, repoInfo.repo, ref, token, tempDir, flags.verbose);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
repoRoot = this.cloneRepo(repoInfo.url, ref, token, tempDir, flags.verbose);
|
|
68
|
+
}
|
|
69
|
+
// Determine source directory (optionally scoped to --path or URL path)
|
|
70
|
+
const sourceDir = subPath ? path.join(repoRoot, subPath) : repoRoot;
|
|
71
|
+
if (!fs.existsSync(sourceDir)) {
|
|
72
|
+
this.error(`Path '${subPath}' not found in repository`);
|
|
73
|
+
}
|
|
74
|
+
if (!fs.statSync(sourceDir).isDirectory()) {
|
|
75
|
+
this.error(`Path '${subPath}' is not a directory`);
|
|
76
|
+
}
|
|
77
|
+
// Collect all .xs files
|
|
78
|
+
const xsFiles = this.collectFiles(sourceDir);
|
|
79
|
+
if (xsFiles.length === 0) {
|
|
80
|
+
this.error(`No .xs files found${subPath ? ` in ${subPath}` : ''} in the repository`);
|
|
81
|
+
}
|
|
82
|
+
// Parse each file
|
|
83
|
+
const documents = [];
|
|
84
|
+
for (const filePath of xsFiles) {
|
|
85
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
86
|
+
if (!content)
|
|
87
|
+
continue;
|
|
88
|
+
const parsed = parseDocument(content);
|
|
89
|
+
if (parsed) {
|
|
90
|
+
documents.push(parsed);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (documents.length === 0) {
|
|
94
|
+
this.error('No valid XanoScript documents found in the repository');
|
|
95
|
+
}
|
|
96
|
+
// Write documents to output directory using the same file tree logic as workspace pull
|
|
97
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
98
|
+
const filenameCounters = new Map();
|
|
99
|
+
let writtenCount = 0;
|
|
100
|
+
for (const doc of documents) {
|
|
101
|
+
const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc);
|
|
102
|
+
fs.mkdirSync(typeDir, { recursive: true });
|
|
103
|
+
// Track duplicates per directory
|
|
104
|
+
const dirKey = path.relative(outputDir, typeDir);
|
|
105
|
+
if (!filenameCounters.has(dirKey)) {
|
|
106
|
+
filenameCounters.set(dirKey, new Map());
|
|
107
|
+
}
|
|
108
|
+
const typeCounters = filenameCounters.get(dirKey);
|
|
109
|
+
const count = typeCounters.get(baseName) || 0;
|
|
110
|
+
typeCounters.set(baseName, count + 1);
|
|
111
|
+
const filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
|
|
112
|
+
const filePath = path.join(typeDir, filename);
|
|
113
|
+
fs.writeFileSync(filePath, doc.content, 'utf8');
|
|
114
|
+
writtenCount++;
|
|
115
|
+
}
|
|
116
|
+
const source = subPath ? `${flags.repo} (${subPath})` : flags.repo;
|
|
117
|
+
this.log(`Pulled ${writtenCount} documents from ${source} to ${args.directory}`);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
// Clean up temp directory
|
|
121
|
+
fs.rmSync(tempDir, { force: true, recursive: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Recursively collect all .xs files from a directory, sorted for deterministic ordering.
|
|
126
|
+
*/
|
|
127
|
+
collectFiles(dir) {
|
|
128
|
+
const files = [];
|
|
129
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const fullPath = path.join(dir, entry.name);
|
|
132
|
+
if (entry.isDirectory()) {
|
|
133
|
+
files.push(...this.collectFiles(fullPath));
|
|
134
|
+
}
|
|
135
|
+
else if (entry.isFile() && entry.name.endsWith('.xs')) {
|
|
136
|
+
files.push(fullPath);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return files.sort();
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Clone a git repository using shallow clone.
|
|
143
|
+
*/
|
|
144
|
+
cloneRepo(cloneUrl, ref, token, tempDir, verbose) {
|
|
145
|
+
let url = cloneUrl;
|
|
146
|
+
// Inject token into HTTPS URLs for private repos
|
|
147
|
+
if (token && url.startsWith('https://')) {
|
|
148
|
+
const parsed = new URL(url);
|
|
149
|
+
parsed.username = token;
|
|
150
|
+
url = parsed.toString();
|
|
151
|
+
}
|
|
152
|
+
const args = ['git', 'clone', '--depth', '1'];
|
|
153
|
+
if (ref) {
|
|
154
|
+
args.push('--branch', ref);
|
|
155
|
+
}
|
|
156
|
+
const cloneTarget = path.join(tempDir, 'repo');
|
|
157
|
+
args.push(url, cloneTarget);
|
|
158
|
+
if (verbose) {
|
|
159
|
+
this.log(`Cloning ${cloneUrl}...`);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
execSync(args.join(' '), { stdio: verbose ? 'inherit' : 'pipe' });
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
this.error(`Failed to clone repository: ${error.message}`);
|
|
166
|
+
}
|
|
167
|
+
return cloneTarget;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Fetch a GitHub repository via the tarball API for fast download without requiring git.
|
|
171
|
+
*/
|
|
172
|
+
async fetchGitHubTarball(owner, repo, ref, token, tempDir, verbose) {
|
|
173
|
+
const tarballRef = ref || 'HEAD';
|
|
174
|
+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/tarball/${tarballRef}`;
|
|
175
|
+
const headers = {
|
|
176
|
+
Accept: 'application/vnd.github+json',
|
|
177
|
+
'User-Agent': 'xano-cli',
|
|
178
|
+
};
|
|
179
|
+
if (token) {
|
|
180
|
+
headers.Authorization = `Bearer ${token}`;
|
|
181
|
+
}
|
|
182
|
+
if (verbose) {
|
|
183
|
+
this.log(`Fetching tarball from GitHub: ${owner}/${repo}${ref ? `@${ref}` : ''}...`);
|
|
184
|
+
}
|
|
185
|
+
const response = await fetch(apiUrl, { headers, redirect: 'follow' });
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
const errorText = await response.text();
|
|
188
|
+
if (response.status === 404) {
|
|
189
|
+
this.error(`Repository '${owner}/${repo}' not found (or is private). ` +
|
|
190
|
+
`Use --token to authenticate for private repos.`);
|
|
191
|
+
}
|
|
192
|
+
this.error(`GitHub API request failed (${response.status}): ${errorText}`);
|
|
193
|
+
}
|
|
194
|
+
// Save the tarball to a temp file
|
|
195
|
+
const tarballPath = path.join(tempDir, 'repo.tar.gz');
|
|
196
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
197
|
+
fs.writeFileSync(tarballPath, buffer);
|
|
198
|
+
// Extract the tarball
|
|
199
|
+
const extractDir = path.join(tempDir, 'extracted');
|
|
200
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
201
|
+
try {
|
|
202
|
+
execSync(`tar xzf "${tarballPath}" -C "${extractDir}"`, { stdio: verbose ? 'inherit' : 'pipe' });
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
this.error(`Failed to extract tarball: ${error.message}`);
|
|
206
|
+
}
|
|
207
|
+
// GitHub tarballs have a root directory like "owner-repo-sha/"
|
|
208
|
+
const extractedEntries = fs.readdirSync(extractDir);
|
|
209
|
+
if (extractedEntries.length === 1) {
|
|
210
|
+
const rootDir = path.join(extractDir, extractedEntries[0]);
|
|
211
|
+
if (fs.statSync(rootDir).isDirectory()) {
|
|
212
|
+
return rootDir;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return extractDir;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Parse a repository URL to extract host, owner, repo, ref, and path.
|
|
219
|
+
* Supports various URL formats:
|
|
220
|
+
*
|
|
221
|
+
* GitHub:
|
|
222
|
+
* https://github.com/owner/repo
|
|
223
|
+
* https://github.com/owner/repo.git
|
|
224
|
+
* git@github.com:owner/repo.git
|
|
225
|
+
* https://github.com/owner/repo/tree/main/path/to/dir
|
|
226
|
+
* https://github.com/owner/repo/blob/main/path/to/file.xs
|
|
227
|
+
* https://raw.githubusercontent.com/owner/repo/refs/heads/main/path/to/file.xs
|
|
228
|
+
*
|
|
229
|
+
* GitLab:
|
|
230
|
+
* https://gitlab.com/owner/repo
|
|
231
|
+
* https://gitlab.com/owner/repo/-/tree/master/path/to/dir
|
|
232
|
+
* https://gitlab.com/owner/repo/-/blob/master/path/to/file
|
|
233
|
+
*
|
|
234
|
+
* Other git URLs passed through for git clone.
|
|
235
|
+
*/
|
|
236
|
+
parseRepoUrl(inputUrl) {
|
|
237
|
+
// GitHub SSH: git@github.com:owner/repo.git
|
|
238
|
+
const sshMatch = inputUrl.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
239
|
+
if (sshMatch) {
|
|
240
|
+
return { host: 'github', owner: sshMatch[1], pathInRepo: '', ref: '', repo: sshMatch[2], url: inputUrl };
|
|
241
|
+
}
|
|
242
|
+
// raw.githubusercontent.com: https://raw.githubusercontent.com/owner/repo/refs/heads/branch/path
|
|
243
|
+
const rawGhMatch = inputUrl.match(/^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/refs\/heads\/([^/]+)\/?(.*)/);
|
|
244
|
+
if (rawGhMatch) {
|
|
245
|
+
const filePath = rawGhMatch[4] || '';
|
|
246
|
+
// Strip the filename to get the directory path, since this points to a single file
|
|
247
|
+
const dirPath = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
|
|
248
|
+
return {
|
|
249
|
+
host: 'github',
|
|
250
|
+
owner: rawGhMatch[1],
|
|
251
|
+
pathInRepo: dirPath,
|
|
252
|
+
ref: rawGhMatch[3],
|
|
253
|
+
repo: rawGhMatch[2],
|
|
254
|
+
url: inputUrl,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
let parsed;
|
|
258
|
+
try {
|
|
259
|
+
parsed = new URL(inputUrl);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Not a URL — treat as a generic git clone target
|
|
263
|
+
return { host: 'other', owner: '', pathInRepo: '', ref: '', repo: '', url: inputUrl };
|
|
264
|
+
}
|
|
265
|
+
const host = parsed.hostname.toLowerCase();
|
|
266
|
+
const pathParts = parsed.pathname
|
|
267
|
+
.replace(/^\//, '')
|
|
268
|
+
.replace(/\.git$/, '')
|
|
269
|
+
.split('/');
|
|
270
|
+
// GitHub HTTPS
|
|
271
|
+
if (host === 'github.com' && pathParts.length >= 2) {
|
|
272
|
+
const owner = pathParts[0];
|
|
273
|
+
const repo = pathParts[1];
|
|
274
|
+
// https://github.com/owner/repo/tree/branch/path/to/dir
|
|
275
|
+
// https://github.com/owner/repo/blob/branch/path/to/file.xs
|
|
276
|
+
if (pathParts.length >= 4 && (pathParts[2] === 'tree' || pathParts[2] === 'blob')) {
|
|
277
|
+
const ref = pathParts[3];
|
|
278
|
+
const subPath = pathParts.slice(4).join('/');
|
|
279
|
+
let dirPath = subPath;
|
|
280
|
+
// For blob links, strip the filename to get directory
|
|
281
|
+
if (pathParts[2] === 'blob' && subPath.includes('/')) {
|
|
282
|
+
dirPath = subPath.slice(0, subPath.lastIndexOf('/'));
|
|
283
|
+
}
|
|
284
|
+
else if (pathParts[2] === 'blob') {
|
|
285
|
+
dirPath = ''; // File is at repo root
|
|
286
|
+
}
|
|
287
|
+
return { host: 'github', owner, pathInRepo: dirPath, ref, repo, url: inputUrl };
|
|
288
|
+
}
|
|
289
|
+
// https://github.com/owner/repo (root)
|
|
290
|
+
return { host: 'github', owner, pathInRepo: '', ref: '', repo, url: inputUrl };
|
|
291
|
+
}
|
|
292
|
+
// GitLab HTTPS
|
|
293
|
+
if (host === 'gitlab.com' && pathParts.length >= 2) {
|
|
294
|
+
const owner = pathParts[0];
|
|
295
|
+
const repo = pathParts[1];
|
|
296
|
+
// Reconstruct clean clone URL
|
|
297
|
+
const cloneUrl = `https://gitlab.com/${owner}/${repo}.git`;
|
|
298
|
+
// https://gitlab.com/owner/repo/-/tree/branch/path/to/dir
|
|
299
|
+
// https://gitlab.com/owner/repo/-/blob/branch/path/to/file
|
|
300
|
+
const dashIdx = pathParts.indexOf('-');
|
|
301
|
+
if (dashIdx >= 2 && pathParts.length >= dashIdx + 3) {
|
|
302
|
+
const action = pathParts[dashIdx + 1]; // tree, blob, or raw
|
|
303
|
+
if (action === 'tree' || action === 'blob' || action === 'raw') {
|
|
304
|
+
const ref = pathParts[dashIdx + 2];
|
|
305
|
+
const subPath = pathParts.slice(dashIdx + 3).join('/');
|
|
306
|
+
let dirPath = subPath;
|
|
307
|
+
if ((action === 'blob' || action === 'raw') && subPath.includes('/')) {
|
|
308
|
+
dirPath = subPath.slice(0, subPath.lastIndexOf('/'));
|
|
309
|
+
}
|
|
310
|
+
else if (action === 'blob' || action === 'raw') {
|
|
311
|
+
dirPath = '';
|
|
312
|
+
}
|
|
313
|
+
return { host: 'gitlab', owner, pathInRepo: dirPath, ref, repo, url: cloneUrl };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// https://gitlab.com/owner/repo (root)
|
|
317
|
+
return { host: 'gitlab', owner, pathInRepo: '', ref: '', repo, url: cloneUrl };
|
|
318
|
+
}
|
|
319
|
+
// Other git URLs — pass through for git clone
|
|
320
|
+
return { host: 'other', owner: '', pathInRepo: '', ref: '', repo: '', url: inputUrl };
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Resolve the output directory and base filename for a parsed document.
|
|
324
|
+
* Uses the same type-to-directory mapping as workspace pull.
|
|
325
|
+
*/
|
|
326
|
+
resolveOutputPath(outputDir, doc) {
|
|
327
|
+
let typeDir;
|
|
328
|
+
let baseName;
|
|
329
|
+
if (doc.type === 'workspace') {
|
|
330
|
+
typeDir = path.join(outputDir, 'workspace');
|
|
331
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
332
|
+
}
|
|
333
|
+
else if (doc.type === 'workspace_trigger') {
|
|
334
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
335
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
336
|
+
}
|
|
337
|
+
else if (doc.type === 'agent') {
|
|
338
|
+
typeDir = path.join(outputDir, 'ai', 'agent');
|
|
339
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
340
|
+
}
|
|
341
|
+
else if (doc.type === 'mcp_server') {
|
|
342
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server');
|
|
343
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
344
|
+
}
|
|
345
|
+
else if (doc.type === 'tool') {
|
|
346
|
+
typeDir = path.join(outputDir, 'ai', 'tool');
|
|
347
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
348
|
+
}
|
|
349
|
+
else if (doc.type === 'agent_trigger') {
|
|
350
|
+
typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
|
|
351
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
352
|
+
}
|
|
353
|
+
else if (doc.type === 'mcp_server_trigger') {
|
|
354
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
|
|
355
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
356
|
+
}
|
|
357
|
+
else if (doc.type === 'table_trigger') {
|
|
358
|
+
typeDir = path.join(outputDir, 'table', 'trigger');
|
|
359
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
360
|
+
}
|
|
361
|
+
else if (doc.type === 'realtime_channel') {
|
|
362
|
+
typeDir = path.join(outputDir, 'realtime', 'channel');
|
|
363
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
364
|
+
}
|
|
365
|
+
else if (doc.type === 'realtime_trigger') {
|
|
366
|
+
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
367
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
368
|
+
}
|
|
369
|
+
else if (doc.type === 'api_group') {
|
|
370
|
+
const groupFolder = snakeCase(doc.name);
|
|
371
|
+
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
372
|
+
baseName = 'api_group';
|
|
373
|
+
}
|
|
374
|
+
else if (doc.type === 'query' && doc.apiGroup) {
|
|
375
|
+
const groupFolder = snakeCase(doc.apiGroup);
|
|
376
|
+
const nameParts = doc.name.split('/');
|
|
377
|
+
const leafName = nameParts.pop();
|
|
378
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
379
|
+
typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
|
|
380
|
+
baseName = this.sanitizeFilename(leafName);
|
|
381
|
+
if (doc.verb) {
|
|
382
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
const nameParts = doc.name.split('/');
|
|
387
|
+
const leafName = nameParts.pop();
|
|
388
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
389
|
+
typeDir = path.join(outputDir, doc.type, ...folderParts);
|
|
390
|
+
baseName = this.sanitizeFilename(leafName);
|
|
391
|
+
if (doc.verb) {
|
|
392
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return { baseName, typeDir };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Sanitize a document name for use as a filename.
|
|
399
|
+
*/
|
|
400
|
+
sanitizeFilename(name) {
|
|
401
|
+
return snakeCase(name.replaceAll('"', ''));
|
|
402
|
+
}
|
|
403
|
+
}
|