@xano/cli 1.0.5 → 1.0.6-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/commands/ephemeral/create/index.d.ts +18 -0
- package/dist/commands/ephemeral/create/index.js +103 -0
- package/dist/commands/ephemeral/delete/index.d.ts +18 -0
- package/dist/commands/ephemeral/delete/index.js +99 -0
- package/dist/commands/ephemeral/edit/index.d.ts +18 -0
- package/dist/commands/ephemeral/edit/index.js +109 -0
- package/dist/commands/ephemeral/get/index.d.ts +21 -0
- package/dist/commands/ephemeral/get/index.js +125 -0
- package/dist/commands/ephemeral/impersonate/index.d.ts +19 -0
- package/dist/commands/ephemeral/impersonate/index.js +112 -0
- package/dist/commands/ephemeral/list/index.d.ts +19 -0
- package/dist/commands/ephemeral/list/index.js +122 -0
- package/dist/commands/ephemeral/pull/index.d.ts +23 -0
- package/dist/commands/ephemeral/pull/index.js +248 -0
- package/dist/commands/ephemeral/push/index.d.ts +23 -0
- package/dist/commands/ephemeral/push/index.js +143 -0
- package/oclif.manifest.json +3739 -2913
- package/package.json +4 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import open from 'open';
|
|
3
|
+
import BaseCommand from '../../../base-command.js';
|
|
4
|
+
export default class EphemeralImpersonate extends BaseCommand {
|
|
5
|
+
static args = {
|
|
6
|
+
tenant_name: Args.string({
|
|
7
|
+
description: 'Ephemeral tenant name to impersonate',
|
|
8
|
+
required: true,
|
|
9
|
+
}),
|
|
10
|
+
};
|
|
11
|
+
static description = 'Impersonate an ephemeral tenant and open it in the browser';
|
|
12
|
+
static examples = [
|
|
13
|
+
`$ xano ephemeral impersonate e4f2-9ab1-xyz1
|
|
14
|
+
Opening browser...
|
|
15
|
+
Impersonation successful!
|
|
16
|
+
`,
|
|
17
|
+
`$ xano ephemeral impersonate e4f2-9ab1-xyz1 -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
|
+
'url-only': Flags.boolean({
|
|
29
|
+
char: 'u',
|
|
30
|
+
default: false,
|
|
31
|
+
description: 'Print the URL without opening the browser',
|
|
32
|
+
required: false,
|
|
33
|
+
}),
|
|
34
|
+
workspace: Flags.string({
|
|
35
|
+
char: 'w',
|
|
36
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
37
|
+
required: false,
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
async run() {
|
|
41
|
+
const { args, flags } = await this.parse(EphemeralImpersonate);
|
|
42
|
+
const { profile } = this.resolveProfile(flags);
|
|
43
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
44
|
+
if (!workspaceId) {
|
|
45
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
46
|
+
}
|
|
47
|
+
const tenantName = args.tenant_name;
|
|
48
|
+
try {
|
|
49
|
+
const response = await this.getImpersonateResponse(profile, workspaceId, tenantName);
|
|
50
|
+
if (flags.output === 'json') {
|
|
51
|
+
this.log(JSON.stringify(response, null, 2));
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const frontendUrl = this.getFrontendUrl(profile.instance_origin);
|
|
55
|
+
const params = new URLSearchParams({
|
|
56
|
+
_ti: response._ti,
|
|
57
|
+
});
|
|
58
|
+
const impersonateUrl = `${frontendUrl}/impersonate?${params.toString()}`;
|
|
59
|
+
if (flags['url-only']) {
|
|
60
|
+
this.log(impersonateUrl);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
this.log('Opening browser...');
|
|
64
|
+
await open(impersonateUrl);
|
|
65
|
+
this.log('Impersonation successful!');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof Error) {
|
|
72
|
+
this.error(`Failed to impersonate tenant: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
this.error(`Failed to impersonate tenant: ${String(error)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async getImpersonateResponse(profile, workspaceId, tenantName) {
|
|
80
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${encodeURIComponent(tenantName)}/impersonate`;
|
|
81
|
+
const { verbose } = await this.parse(EphemeralImpersonate).then((r) => r.flags);
|
|
82
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
85
|
+
accept: 'application/json',
|
|
86
|
+
},
|
|
87
|
+
method: 'GET',
|
|
88
|
+
}, verbose, profile.access_token);
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const errorText = await response.text();
|
|
91
|
+
throw new Error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
92
|
+
}
|
|
93
|
+
const result = (await response.json());
|
|
94
|
+
if (!result._ti) {
|
|
95
|
+
throw new Error('No one-time token returned from impersonate API');
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
getFrontendUrl(instanceOrigin) {
|
|
100
|
+
try {
|
|
101
|
+
const url = new URL(instanceOrigin);
|
|
102
|
+
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
|
|
103
|
+
url.port = '4200';
|
|
104
|
+
return url.origin;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// fall through
|
|
109
|
+
}
|
|
110
|
+
return instanceOrigin;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class EphemeralList extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
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
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
};
|
|
13
|
+
run(): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Render a unix-epoch-seconds expiration as a human-readable "in Xh Ym" string,
|
|
16
|
+
* falling back to an ISO timestamp if the value has already passed or is malformed.
|
|
17
|
+
*/
|
|
18
|
+
private formatExpiration;
|
|
19
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
export default class EphemeralList extends BaseCommand {
|
|
4
|
+
static description = 'List ephemeral tenants in a workspace, or across the whole instance with --global';
|
|
5
|
+
static examples = [
|
|
6
|
+
`$ xano ephemeral list
|
|
7
|
+
Ephemeral tenants in workspace 5:
|
|
8
|
+
- PR preview (e4f2-9ab1-...) [ok] - expires in 1h 0m
|
|
9
|
+
- Demo (e7a3-2c10-...) [ok] - expires in 23h 12m
|
|
10
|
+
`,
|
|
11
|
+
`$ xano ephemeral list --global
|
|
12
|
+
Ephemeral tenants (all workspaces):
|
|
13
|
+
- PR preview (e4f2-9ab1-...) [ok] - expires in 1h 0m
|
|
14
|
+
`,
|
|
15
|
+
`$ xano ephemeral list -w 5 -o json`,
|
|
16
|
+
];
|
|
17
|
+
static flags = {
|
|
18
|
+
...BaseCommand.baseFlags,
|
|
19
|
+
global: Flags.boolean({
|
|
20
|
+
default: false,
|
|
21
|
+
description: 'List ephemeral tenants across the whole instance (all workspaces)',
|
|
22
|
+
required: false,
|
|
23
|
+
}),
|
|
24
|
+
output: Flags.string({
|
|
25
|
+
char: 'o',
|
|
26
|
+
default: 'summary',
|
|
27
|
+
description: 'Output format',
|
|
28
|
+
options: ['summary', 'json'],
|
|
29
|
+
required: false,
|
|
30
|
+
}),
|
|
31
|
+
workspace: Flags.string({
|
|
32
|
+
char: 'w',
|
|
33
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
34
|
+
required: false,
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
async run() {
|
|
38
|
+
const { flags } = await this.parse(EphemeralList);
|
|
39
|
+
const { profile } = this.resolveProfile(flags);
|
|
40
|
+
let workspaceId;
|
|
41
|
+
let apiUrl;
|
|
42
|
+
if (flags.global) {
|
|
43
|
+
apiUrl = `${profile.instance_origin}/api:meta/ephemeral`;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
workspaceId = flags.workspace || profile.workspace;
|
|
47
|
+
if (!workspaceId) {
|
|
48
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
49
|
+
}
|
|
50
|
+
apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/ephemeral`;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
54
|
+
headers: {
|
|
55
|
+
accept: 'application/json',
|
|
56
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
57
|
+
},
|
|
58
|
+
method: 'GET',
|
|
59
|
+
}, flags.verbose, profile.access_token);
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const errorText = await response.text();
|
|
62
|
+
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
63
|
+
}
|
|
64
|
+
const data = (await response.json());
|
|
65
|
+
let tenants;
|
|
66
|
+
if (Array.isArray(data)) {
|
|
67
|
+
tenants = data;
|
|
68
|
+
}
|
|
69
|
+
else if (data && typeof data === 'object' && Array.isArray(data.items)) {
|
|
70
|
+
tenants = data.items;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.error('Unexpected API response format');
|
|
74
|
+
}
|
|
75
|
+
if (flags.output === 'json') {
|
|
76
|
+
this.log(JSON.stringify(tenants, null, 2));
|
|
77
|
+
}
|
|
78
|
+
else if (tenants.length === 0) {
|
|
79
|
+
this.log('No ephemeral tenants found');
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.log(flags.global ? 'Ephemeral tenants (all workspaces):' : `Ephemeral tenants in workspace ${workspaceId}:`);
|
|
83
|
+
for (const tenant of tenants) {
|
|
84
|
+
const state = tenant.state ? ` [${tenant.state}]` : '';
|
|
85
|
+
const expires = ` - expires ${this.formatExpiration(tenant.ephemeral_expires_at)}`;
|
|
86
|
+
const workspaceLabel = flags.global && tenant.workspace_id ? ` (workspace ${tenant.workspace_id})` : '';
|
|
87
|
+
this.log(` - ${tenant.display || tenant.name} (${tenant.name})${state}${expires}${workspaceLabel}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error instanceof Error) {
|
|
93
|
+
this.error(`Failed to list ephemeral tenants: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
this.error(`Failed to list ephemeral tenants: ${String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Render a unix-epoch-seconds expiration as a human-readable "in Xh Ym" string,
|
|
102
|
+
* falling back to an ISO timestamp if the value has already passed or is malformed.
|
|
103
|
+
*/
|
|
104
|
+
formatExpiration(expiresAt) {
|
|
105
|
+
if (!expiresAt) {
|
|
106
|
+
return '-';
|
|
107
|
+
}
|
|
108
|
+
// The API serializes the timestamp field as a date string
|
|
109
|
+
// ("2026-07-24 19:49:15+0000"); tolerate a raw unix-epoch number too.
|
|
110
|
+
const expiresMs = typeof expiresAt === 'number' ? expiresAt * 1000 : Date.parse(String(expiresAt).replace(' ', 'T'));
|
|
111
|
+
if (Number.isNaN(expiresMs)) {
|
|
112
|
+
return String(expiresAt);
|
|
113
|
+
}
|
|
114
|
+
const diffSeconds = (expiresMs - Date.now()) / 1000;
|
|
115
|
+
if (diffSeconds <= 0) {
|
|
116
|
+
return `${new Date(expiresMs).toISOString()} (expired)`;
|
|
117
|
+
}
|
|
118
|
+
const hours = Math.floor(diffSeconds / 3600);
|
|
119
|
+
const minutes = Math.floor((diffSeconds % 3600) / 60);
|
|
120
|
+
return `in ${hours}h ${minutes}m`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class EphemeralPull extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
directory: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
draft: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
tenant: 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
|
+
/**
|
|
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
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import snakeCase from 'lodash.snakecase';
|
|
5
|
+
import BaseCommand from '../../../base-command.js';
|
|
6
|
+
import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
|
|
7
|
+
export default class EphemeralPull extends BaseCommand {
|
|
8
|
+
static description = 'Pull an ephemeral tenant multidoc from the Xano Metadata API and split into individual files';
|
|
9
|
+
static examples = [
|
|
10
|
+
`$ xano ephemeral pull -t e4f2-9ab1-xyz1
|
|
11
|
+
Pulled 42 documents from tenant e4f2-9ab1-xyz1 to current directory
|
|
12
|
+
`,
|
|
13
|
+
`$ xano ephemeral pull -d ./my-tenant -t e4f2-9ab1-xyz1
|
|
14
|
+
Pulled 42 documents from tenant e4f2-9ab1-xyz1 to ./my-tenant
|
|
15
|
+
`,
|
|
16
|
+
`$ xano ephemeral pull -d ./output -t e4f2-9ab1-xyz1 -w 40
|
|
17
|
+
Pulled 15 documents from tenant e4f2-9ab1-xyz1 to ./output
|
|
18
|
+
`,
|
|
19
|
+
`$ xano ephemeral pull -t e4f2-9ab1-xyz1 --profile production --env --records
|
|
20
|
+
Pulled 58 documents from tenant e4f2-9ab1-xyz1
|
|
21
|
+
`,
|
|
22
|
+
`$ xano ephemeral pull -t e4f2-9ab1-xyz1 --draft`,
|
|
23
|
+
];
|
|
24
|
+
static flags = {
|
|
25
|
+
...BaseCommand.baseFlags,
|
|
26
|
+
directory: Flags.string({
|
|
27
|
+
char: 'd',
|
|
28
|
+
default: '.',
|
|
29
|
+
description: 'Output directory for pulled documents (defaults to current directory)',
|
|
30
|
+
required: false,
|
|
31
|
+
}),
|
|
32
|
+
draft: Flags.boolean({
|
|
33
|
+
default: false,
|
|
34
|
+
description: 'Include draft versions',
|
|
35
|
+
required: false,
|
|
36
|
+
}),
|
|
37
|
+
env: Flags.boolean({
|
|
38
|
+
default: false,
|
|
39
|
+
description: 'Include environment variables',
|
|
40
|
+
required: false,
|
|
41
|
+
}),
|
|
42
|
+
records: Flags.boolean({
|
|
43
|
+
default: false,
|
|
44
|
+
description: 'Include records',
|
|
45
|
+
required: false,
|
|
46
|
+
}),
|
|
47
|
+
tenant: Flags.string({
|
|
48
|
+
char: 't',
|
|
49
|
+
description: 'Ephemeral tenant name to pull from',
|
|
50
|
+
required: true,
|
|
51
|
+
}),
|
|
52
|
+
workspace: Flags.string({
|
|
53
|
+
char: 'w',
|
|
54
|
+
description: 'Workspace ID (optional if set in profile)',
|
|
55
|
+
required: false,
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
async run() {
|
|
59
|
+
const { flags } = await this.parse(EphemeralPull);
|
|
60
|
+
const { profileName, profile } = this.resolveProfile(flags);
|
|
61
|
+
// Determine workspace_id from flag or profile
|
|
62
|
+
let workspaceId;
|
|
63
|
+
if (flags.workspace) {
|
|
64
|
+
workspaceId = flags.workspace;
|
|
65
|
+
}
|
|
66
|
+
else if (profile.workspace) {
|
|
67
|
+
workspaceId = profile.workspace;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.error(`Workspace ID is required. Either:\n` +
|
|
71
|
+
` 1. Provide it as a flag: xano ephemeral pull -t <tenant_name> -w <workspace_id>\n` +
|
|
72
|
+
` 2. Set it in your profile using: xano profile:edit ${profileName} -w <workspace_id>`);
|
|
73
|
+
}
|
|
74
|
+
const tenantName = flags.tenant;
|
|
75
|
+
// Build query parameters
|
|
76
|
+
const queryParams = new URLSearchParams({
|
|
77
|
+
env: flags.env.toString(),
|
|
78
|
+
include_draft: flags.draft.toString(),
|
|
79
|
+
records: flags.records.toString(),
|
|
80
|
+
});
|
|
81
|
+
// Construct the API URL
|
|
82
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/multidoc?${queryParams.toString()}`;
|
|
83
|
+
// Fetch multidoc from the API
|
|
84
|
+
let responseText;
|
|
85
|
+
const requestHeaders = {
|
|
86
|
+
accept: 'application/json',
|
|
87
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
91
|
+
headers: requestHeaders,
|
|
92
|
+
method: 'GET',
|
|
93
|
+
}, flags.verbose, profile.access_token);
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const errorText = await response.text();
|
|
96
|
+
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
97
|
+
}
|
|
98
|
+
responseText = await response.text();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error instanceof Error) {
|
|
102
|
+
this.error(`Failed to fetch multidoc: ${error.message}`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
this.error(`Failed to fetch multidoc: ${String(error)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Split the response into individual documents
|
|
109
|
+
const rawDocuments = responseText.split('\n---\n');
|
|
110
|
+
// Parse each document
|
|
111
|
+
const documents = [];
|
|
112
|
+
for (const raw of rawDocuments) {
|
|
113
|
+
const trimmed = raw.trim();
|
|
114
|
+
if (!trimmed) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const parsed = parseDocument(trimmed);
|
|
118
|
+
if (parsed) {
|
|
119
|
+
documents.push(parsed);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (documents.length === 0) {
|
|
123
|
+
this.log('No documents found in response');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Resolve the output directory
|
|
127
|
+
const outputDir = path.resolve(flags.directory);
|
|
128
|
+
// Create the output directory if it doesn't exist
|
|
129
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
130
|
+
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
131
|
+
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
132
|
+
// Track filenames per type to handle duplicates
|
|
133
|
+
const filenameCounters = new Map();
|
|
134
|
+
let writtenCount = 0;
|
|
135
|
+
for (const doc of documents) {
|
|
136
|
+
let typeDir;
|
|
137
|
+
let baseName;
|
|
138
|
+
if (doc.type === 'workspace') {
|
|
139
|
+
// workspace → workspace/{name}.xs
|
|
140
|
+
typeDir = path.join(outputDir, 'workspace');
|
|
141
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
142
|
+
}
|
|
143
|
+
else if (doc.type === 'workspace_trigger') {
|
|
144
|
+
// workspace_trigger → workspace/trigger/{name}.xs
|
|
145
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
146
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
147
|
+
}
|
|
148
|
+
else if (doc.type === 'error_trigger') {
|
|
149
|
+
// error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
|
|
150
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
151
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
152
|
+
}
|
|
153
|
+
else if (doc.type === 'agent') {
|
|
154
|
+
// agent → ai/agent/{name}.xs
|
|
155
|
+
typeDir = path.join(outputDir, 'ai', 'agent');
|
|
156
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
157
|
+
}
|
|
158
|
+
else if (doc.type === 'mcp_server') {
|
|
159
|
+
// mcp_server → ai/mcp_server/{name}.xs
|
|
160
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server');
|
|
161
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
162
|
+
}
|
|
163
|
+
else if (doc.type === 'tool') {
|
|
164
|
+
// tool → ai/tool/{name}.xs
|
|
165
|
+
typeDir = path.join(outputDir, 'ai', 'tool');
|
|
166
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
167
|
+
}
|
|
168
|
+
else if (doc.type === 'agent_trigger') {
|
|
169
|
+
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
170
|
+
typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
|
|
171
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
172
|
+
}
|
|
173
|
+
else if (doc.type === 'mcp_server_trigger') {
|
|
174
|
+
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
175
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
|
|
176
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
177
|
+
}
|
|
178
|
+
else if (doc.type === 'table_trigger') {
|
|
179
|
+
// table_trigger → table/trigger/{name}.xs
|
|
180
|
+
typeDir = path.join(outputDir, 'table', 'trigger');
|
|
181
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
182
|
+
}
|
|
183
|
+
else if (doc.type === 'realtime_channel') {
|
|
184
|
+
// realtime_channel → realtime/channel/{name}.xs
|
|
185
|
+
typeDir = path.join(outputDir, 'realtime', 'channel');
|
|
186
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
187
|
+
}
|
|
188
|
+
else if (doc.type === 'realtime_trigger') {
|
|
189
|
+
// realtime_trigger → realtime/trigger/{name}.xs
|
|
190
|
+
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
191
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
192
|
+
}
|
|
193
|
+
else if (doc.type === 'api_group') {
|
|
194
|
+
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
195
|
+
const groupFolder = getApiGroupFolder(doc.name);
|
|
196
|
+
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
197
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
198
|
+
}
|
|
199
|
+
else if (doc.type === 'query' && doc.apiGroup) {
|
|
200
|
+
// query in group "test" → api/{resolved_folder}/{query_name}.xs
|
|
201
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
202
|
+
const nameParts = doc.name.split('/');
|
|
203
|
+
const leafName = nameParts.pop();
|
|
204
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
205
|
+
typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
|
|
206
|
+
baseName = this.sanitizeFilename(leafName);
|
|
207
|
+
if (doc.verb) {
|
|
208
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// Default: split folder path from name
|
|
213
|
+
const nameParts = doc.name.split('/');
|
|
214
|
+
const leafName = nameParts.pop();
|
|
215
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
216
|
+
typeDir = path.join(outputDir, doc.type, ...folderParts);
|
|
217
|
+
baseName = this.sanitizeFilename(leafName);
|
|
218
|
+
if (doc.verb) {
|
|
219
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
fs.mkdirSync(typeDir, { recursive: true });
|
|
223
|
+
// Track duplicates per directory
|
|
224
|
+
const dirKey = path.relative(outputDir, typeDir);
|
|
225
|
+
if (!filenameCounters.has(dirKey)) {
|
|
226
|
+
filenameCounters.set(dirKey, new Map());
|
|
227
|
+
}
|
|
228
|
+
const typeCounters = filenameCounters.get(dirKey);
|
|
229
|
+
const count = typeCounters.get(baseName) || 0;
|
|
230
|
+
typeCounters.set(baseName, count + 1);
|
|
231
|
+
// Append numeric suffix for duplicates
|
|
232
|
+
let filename;
|
|
233
|
+
filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
|
|
234
|
+
const filePath = path.join(typeDir, filename);
|
|
235
|
+
fs.writeFileSync(filePath, doc.content, 'utf8');
|
|
236
|
+
writtenCount++;
|
|
237
|
+
}
|
|
238
|
+
this.log(`Pulled ${writtenCount} documents from tenant ${tenantName} to ${flags.directory}`);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Sanitize a document name for use as a filename.
|
|
242
|
+
* Strips quotes, replaces spaces with underscores, and removes
|
|
243
|
+
* characters that are unsafe in filenames.
|
|
244
|
+
*/
|
|
245
|
+
sanitizeFilename(name) {
|
|
246
|
+
return snakeCase(name.replaceAll('"', ''));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class EphemeralPush extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
directory: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
delete: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
guids: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
sync: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
tenant: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
transaction: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
truncate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
21
|
+
};
|
|
22
|
+
run(): Promise<void>;
|
|
23
|
+
}
|