@xano/cli 1.0.6-beta.15 → 1.0.6-beta.17
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 +6 -0
- package/dist/commands/function/run/index.d.ts +1 -0
- package/dist/commands/function/run/index.js +19 -5
- package/dist/utils/multidoc-push.d.ts +54 -0
- package/dist/utils/multidoc-push.js +238 -8
- package/oclif.manifest.json +2286 -2277
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -323,6 +323,7 @@ xano function run <name> --data email=jo@x.com --data age:=30 --data active:=tru
|
|
|
323
323
|
xano function run <name> --json @payload.json --data env=staging # base payload + override
|
|
324
324
|
echo '{"email":"jo@x.com"}' | xano function run <name> --stdin -o json | jq .result
|
|
325
325
|
xano function run <name> --branch dev --logs # run on a branch, show execution logs
|
|
326
|
+
xano function run <name> --datasource test # run against the 'test' data source
|
|
326
327
|
```
|
|
327
328
|
|
|
328
329
|
Input flexibility for `function run` (assembled into one JSON `input` object):
|
|
@@ -335,6 +336,11 @@ Input flexibility for `function run` (assembled into one JSON `input` object):
|
|
|
335
336
|
| `--json '<inline>'` / `--json @file.json` / `--json -` | a base JSON object (stdin with `-`) |
|
|
336
337
|
| `--stdin` | read the JSON object from stdin (same as `--json -`) |
|
|
337
338
|
|
|
339
|
+
`--datasource <label>` runs the function against a non-live data source by sending the
|
|
340
|
+
`X-Data-Source` header, so table reads and writes hit that data source's tables. Omit it
|
|
341
|
+
to run against `live`; an unknown label is rejected by the server with
|
|
342
|
+
`Invalid data source.`
|
|
343
|
+
|
|
338
344
|
Merge order is JSON base first, then `--data` overrides. Missing required inputs are
|
|
339
345
|
prompted for on an interactive terminal; in a non-TTY (CI) context the command fails
|
|
340
346
|
listing them. Output defaults to the raw `result` (`-o json`); the exit code is non-zero
|
|
@@ -8,6 +8,7 @@ export default class FunctionRun extends BaseCommand {
|
|
|
8
8
|
static flags: {
|
|
9
9
|
branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
10
|
data: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
datasource: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
12
|
json: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
13
|
logs: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
14
|
name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -17,6 +17,8 @@ export default class FunctionRun extends BaseCommand {
|
|
|
17
17
|
`$ xano function:run calcScore --json @payload.json --data env=staging`,
|
|
18
18
|
`$ echo '{"email":"jo@x.com"}' | xano function:run calcScore --stdin -o json | jq .result`,
|
|
19
19
|
`$ xano function:run calcScore --branch dev --logs`,
|
|
20
|
+
`$ xano function:run calcScore --datasource test
|
|
21
|
+
# Runs against the 'test' data source instead of 'live'`,
|
|
20
22
|
];
|
|
21
23
|
static flags = {
|
|
22
24
|
...BaseCommand.baseFlags,
|
|
@@ -30,6 +32,10 @@ export default class FunctionRun extends BaseCommand {
|
|
|
30
32
|
multiple: true,
|
|
31
33
|
required: false,
|
|
32
34
|
}),
|
|
35
|
+
datasource: Flags.string({
|
|
36
|
+
description: 'Data source to run against, e.g. test (defaults to live)',
|
|
37
|
+
required: false,
|
|
38
|
+
}),
|
|
33
39
|
json: Flags.string({
|
|
34
40
|
description: "Input as a JSON object: inline, @file.json, or '-' for stdin",
|
|
35
41
|
exclusive: ['stdin'],
|
|
@@ -106,13 +112,21 @@ export default class FunctionRun extends BaseCommand {
|
|
|
106
112
|
const body = { input, name: functionName };
|
|
107
113
|
if (branch)
|
|
108
114
|
body.branch = branch;
|
|
115
|
+
const headers = {
|
|
116
|
+
'accept': 'application/json',
|
|
117
|
+
'Authorization': `Bearer ${profile.access_token}`,
|
|
118
|
+
'Content-Type': 'application/json',
|
|
119
|
+
};
|
|
120
|
+
// The Metadata API resolves the workspace's tables against the data source
|
|
121
|
+
// named by X-Data-Source (defaulting to `live`), so the run reads and writes
|
|
122
|
+
// the same tables a request carrying that header would. An unknown label is
|
|
123
|
+
// rejected server-side with "Invalid data source."
|
|
124
|
+
const dataSource = flags.datasource?.trim();
|
|
125
|
+
if (dataSource)
|
|
126
|
+
headers['X-Data-Source'] = dataSource;
|
|
109
127
|
const response = await this.verboseFetch(apiUrl, {
|
|
110
128
|
body: JSON.stringify(body),
|
|
111
|
-
headers
|
|
112
|
-
'accept': 'application/json',
|
|
113
|
-
'Authorization': `Bearer ${profile.access_token}`,
|
|
114
|
-
'Content-Type': 'application/json',
|
|
115
|
-
},
|
|
129
|
+
headers,
|
|
116
130
|
method: 'POST',
|
|
117
131
|
}, flags.verbose, profile.access_token);
|
|
118
132
|
if (!response.ok) {
|
|
@@ -89,7 +89,61 @@ export declare function collectFiles(dir: string): string[];
|
|
|
89
89
|
* Apply include/exclude glob filters to a file list. Logs filter results.
|
|
90
90
|
* Returns the filtered file list.
|
|
91
91
|
*/
|
|
92
|
+
/**
|
|
93
|
+
* Normalize a filter pattern into one minimatch can actually match against a
|
|
94
|
+
* repo-relative FILE path.
|
|
95
|
+
*
|
|
96
|
+
* minimatch has no notion of "this pattern names a directory", and the two
|
|
97
|
+
* spellings a person reaches for first both silently match NOTHING:
|
|
98
|
+
*
|
|
99
|
+
* "table/" a trailing slash is not directory syntax; it matches nothing
|
|
100
|
+
* "table" no slash, so `matchBase` compares it to the file's BASENAME,
|
|
101
|
+
* i.e. it asks "is this file called `table`", not "is it under
|
|
102
|
+
* `table/`"
|
|
103
|
+
*
|
|
104
|
+
* Silently matching nothing is the dangerous half of this: combined with
|
|
105
|
+
* `--delete`, a filter that fails open pushes more than intended, and a filter
|
|
106
|
+
* that succeeds deletes what it was meant to protect. So a pattern that names a
|
|
107
|
+
* real directory is expanded to `<dir>/**` rather than left to match nothing.
|
|
108
|
+
*/
|
|
109
|
+
export declare function normalizeFilterPattern(pattern: string, inputDir: string): string;
|
|
92
110
|
export declare function applyFilters(files: string[], inputDir: string, include: string[] | undefined, exclude: string[] | undefined, log: (msg: string) => void): string[];
|
|
111
|
+
/** A document the filter removed from the push, and therefore must protect. */
|
|
112
|
+
export interface FilteredOutDocument {
|
|
113
|
+
guid?: string;
|
|
114
|
+
key: string;
|
|
115
|
+
name: string;
|
|
116
|
+
relPath: string;
|
|
117
|
+
type: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Describe the documents a --include/--exclude filter removed.
|
|
121
|
+
*
|
|
122
|
+
* These are the objects the user asked NOT to touch, and they are exactly the
|
|
123
|
+
* ones a `--delete` sweep would remove: a filtered-out document never reaches
|
|
124
|
+
* the payload, so the server reads it as deleted from the tree. Their GUIDs are
|
|
125
|
+
* sent as `protect_guids` so the server spares them, and their keys are used to
|
|
126
|
+
* VERIFY the server actually did.
|
|
127
|
+
*/
|
|
128
|
+
export declare function describeFilteredOut(allFiles: string[], keptFiles: string[], inputDir: string): FilteredOutDocument[];
|
|
129
|
+
/**
|
|
130
|
+
* Delete operations that would remove something the filter excluded.
|
|
131
|
+
*
|
|
132
|
+
* This is the guard's actual test, and it is deliberately performed against the
|
|
133
|
+
* dry-run the SERVER returned rather than trusting that `protect_guids` was
|
|
134
|
+
* honoured: an older instance ignores the parameter entirely, and a document
|
|
135
|
+
* with no GUID cannot be protected at all. Either way the answer here is the
|
|
136
|
+
* truth about what the push is about to do.
|
|
137
|
+
*/
|
|
138
|
+
export declare function deletesHittingFilteredOut(operations: Array<{
|
|
139
|
+
action: string;
|
|
140
|
+
name: string;
|
|
141
|
+
type: string;
|
|
142
|
+
}>, filteredOut: FilteredOutDocument[]): Array<{
|
|
143
|
+
name: string;
|
|
144
|
+
relPath: string;
|
|
145
|
+
type: string;
|
|
146
|
+
}>;
|
|
93
147
|
/**
|
|
94
148
|
* Read .xs files into document entries, skipping empty files.
|
|
95
149
|
*/
|
|
@@ -66,30 +66,151 @@ export function collectFiles(dir) {
|
|
|
66
66
|
* Apply include/exclude glob filters to a file list. Logs filter results.
|
|
67
67
|
* Returns the filtered file list.
|
|
68
68
|
*/
|
|
69
|
+
/**
|
|
70
|
+
* Normalize a filter pattern into one minimatch can actually match against a
|
|
71
|
+
* repo-relative FILE path.
|
|
72
|
+
*
|
|
73
|
+
* minimatch has no notion of "this pattern names a directory", and the two
|
|
74
|
+
* spellings a person reaches for first both silently match NOTHING:
|
|
75
|
+
*
|
|
76
|
+
* "table/" a trailing slash is not directory syntax; it matches nothing
|
|
77
|
+
* "table" no slash, so `matchBase` compares it to the file's BASENAME,
|
|
78
|
+
* i.e. it asks "is this file called `table`", not "is it under
|
|
79
|
+
* `table/`"
|
|
80
|
+
*
|
|
81
|
+
* Silently matching nothing is the dangerous half of this: combined with
|
|
82
|
+
* `--delete`, a filter that fails open pushes more than intended, and a filter
|
|
83
|
+
* that succeeds deletes what it was meant to protect. So a pattern that names a
|
|
84
|
+
* real directory is expanded to `<dir>/**` rather than left to match nothing.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeFilterPattern(pattern, inputDir) {
|
|
87
|
+
const trimmed = pattern.replace(/[/\\]+$/, '');
|
|
88
|
+
if (trimmed === '')
|
|
89
|
+
return pattern;
|
|
90
|
+
// Anything with a glob character is taken exactly as written.
|
|
91
|
+
if (trimmed.includes('*') || trimmed.includes('?') || trimmed.includes('[')) {
|
|
92
|
+
return trimmed === pattern ? pattern : trimmed;
|
|
93
|
+
}
|
|
94
|
+
// A trailing slash is an unambiguous "this is a directory".
|
|
95
|
+
if (trimmed !== pattern)
|
|
96
|
+
return `${trimmed}/**`;
|
|
97
|
+
try {
|
|
98
|
+
if (fs.statSync(join(inputDir, trimmed)).isDirectory())
|
|
99
|
+
return `${trimmed}/**`;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Not a path in this tree - leave it alone (it may be a bare filename,
|
|
103
|
+
// which matchBase handles).
|
|
104
|
+
}
|
|
105
|
+
return pattern;
|
|
106
|
+
}
|
|
69
107
|
export function applyFilters(files, inputDir, include, exclude, log) {
|
|
70
108
|
let filtered = files;
|
|
71
109
|
const totalCount = files.length;
|
|
110
|
+
// Report each pattern's own match count. The aggregate line alone hides a dead
|
|
111
|
+
// pattern among live ones: a run with three -e patterns where one is a typo
|
|
112
|
+
// still prints a plausible number and says nothing about which one matched
|
|
113
|
+
// nothing.
|
|
114
|
+
const describe = (patterns, rels) => patterns
|
|
115
|
+
.map((raw) => {
|
|
116
|
+
const pattern = normalizeFilterPattern(raw, inputDir);
|
|
117
|
+
const hits = rels.filter((rel) => minimatch(rel, pattern, { matchBase: true })).length;
|
|
118
|
+
const shown = pattern === raw ? raw : `${raw} -> ${pattern}`;
|
|
119
|
+
return hits === 0
|
|
120
|
+
? `${ux.colorize('yellow', shown)} ${ux.colorize('yellow', '(matched 0 files)')}`
|
|
121
|
+
: `${ux.colorize('cyan', shown)} ${ux.colorize('dim', `(${hits})`)}`;
|
|
122
|
+
})
|
|
123
|
+
.join(', ');
|
|
72
124
|
if (include && include.length > 0) {
|
|
125
|
+
const rels = filtered.map((f) => relative(inputDir, f));
|
|
126
|
+
const patterns = include.map((p) => normalizeFilterPattern(p, inputDir));
|
|
127
|
+
log('');
|
|
128
|
+
log(` ${ux.colorize('dim', 'Include:')} ${describe(include, rels)}`);
|
|
73
129
|
filtered = filtered.filter((f) => {
|
|
74
130
|
const rel = relative(inputDir, f);
|
|
75
|
-
return
|
|
131
|
+
return patterns.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
|
|
76
132
|
});
|
|
77
|
-
log('');
|
|
78
|
-
log(` ${ux.colorize('dim', 'Include:')} ${include.map((p) => ux.colorize('cyan', p)).join(', ')}`);
|
|
79
133
|
log(` ${ux.colorize('dim', 'Matched:')} ${ux.colorize('bold', String(filtered.length))} of ${totalCount} files`);
|
|
80
134
|
}
|
|
81
135
|
if (exclude && exclude.length > 0) {
|
|
82
136
|
const beforeCount = filtered.length;
|
|
137
|
+
const rels = filtered.map((f) => relative(inputDir, f));
|
|
138
|
+
const patterns = exclude.map((p) => normalizeFilterPattern(p, inputDir));
|
|
139
|
+
log('');
|
|
140
|
+
log(` ${ux.colorize('dim', 'Exclude:')} ${describe(exclude, rels)}`);
|
|
83
141
|
filtered = filtered.filter((f) => {
|
|
84
142
|
const rel = relative(inputDir, f);
|
|
85
|
-
return !
|
|
143
|
+
return !patterns.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
|
|
86
144
|
});
|
|
87
|
-
log('');
|
|
88
|
-
log(` ${ux.colorize('dim', 'Exclude:')} ${exclude.map((p) => ux.colorize('cyan', p)).join(', ')}`);
|
|
89
145
|
log(` ${ux.colorize('dim', 'Kept:')} ${ux.colorize('bold', String(filtered.length))} of ${beforeCount} files (excluded ${beforeCount - filtered.length})`);
|
|
90
146
|
}
|
|
91
147
|
return filtered;
|
|
92
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Describe the documents a --include/--exclude filter removed.
|
|
151
|
+
*
|
|
152
|
+
* These are the objects the user asked NOT to touch, and they are exactly the
|
|
153
|
+
* ones a `--delete` sweep would remove: a filtered-out document never reaches
|
|
154
|
+
* the payload, so the server reads it as deleted from the tree. Their GUIDs are
|
|
155
|
+
* sent as `protect_guids` so the server spares them, and their keys are used to
|
|
156
|
+
* VERIFY the server actually did.
|
|
157
|
+
*/
|
|
158
|
+
export function describeFilteredOut(allFiles, keptFiles, inputDir) {
|
|
159
|
+
const kept = new Set(keptFiles);
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const filePath of allFiles) {
|
|
162
|
+
if (kept.has(filePath))
|
|
163
|
+
continue;
|
|
164
|
+
let content;
|
|
165
|
+
try {
|
|
166
|
+
content = fs.readFileSync(filePath, 'utf8').trim();
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!content)
|
|
172
|
+
continue;
|
|
173
|
+
const parsed = parseDocument(content);
|
|
174
|
+
if (!parsed)
|
|
175
|
+
continue;
|
|
176
|
+
const opName = parsed.verb ? `${parsed.name} ${parsed.verb}` : parsed.name;
|
|
177
|
+
out.push({
|
|
178
|
+
guid: parsed.guid,
|
|
179
|
+
// The preview buckets every trigger subtype under the generic `trigger`
|
|
180
|
+
// type, so key triggers that way to match (DEV-7084, same as
|
|
181
|
+
// filterChangedEntries).
|
|
182
|
+
key: `${parsed.type.endsWith('_trigger') ? 'trigger' : parsed.type}:${opName}`,
|
|
183
|
+
name: opName,
|
|
184
|
+
relPath: relative(inputDir, filePath),
|
|
185
|
+
type: parsed.type,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Delete operations that would remove something the filter excluded.
|
|
192
|
+
*
|
|
193
|
+
* This is the guard's actual test, and it is deliberately performed against the
|
|
194
|
+
* dry-run the SERVER returned rather than trusting that `protect_guids` was
|
|
195
|
+
* honoured: an older instance ignores the parameter entirely, and a document
|
|
196
|
+
* with no GUID cannot be protected at all. Either way the answer here is the
|
|
197
|
+
* truth about what the push is about to do.
|
|
198
|
+
*/
|
|
199
|
+
export function deletesHittingFilteredOut(operations, filteredOut) {
|
|
200
|
+
if (filteredOut.length === 0)
|
|
201
|
+
return [];
|
|
202
|
+
const byKey = new Map(filteredOut.map((d) => [d.key, d]));
|
|
203
|
+
const hits = [];
|
|
204
|
+
for (const op of operations) {
|
|
205
|
+
if (op.action !== 'delete' && op.action !== 'cascade_delete')
|
|
206
|
+
continue;
|
|
207
|
+
const match = byKey.get(`${op.type}:${op.name}`);
|
|
208
|
+
if (match) {
|
|
209
|
+
hits.push({ name: op.name, relPath: match.relPath, type: op.type });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return hits;
|
|
213
|
+
}
|
|
93
214
|
/**
|
|
94
215
|
* Read .xs files into document entries, skipping empty files.
|
|
95
216
|
*/
|
|
@@ -174,7 +295,7 @@ const TYPE_LABELS = {
|
|
|
174
295
|
workflow_test: 'Workflow Tests',
|
|
175
296
|
workspace: 'Workspace Settings',
|
|
176
297
|
};
|
|
177
|
-
function renderPreview(result, willDelete, target, verbose, partial, log) {
|
|
298
|
+
function renderPreview(result, willDelete, target, verbose, partial, log, filteredOutCount = 0) {
|
|
178
299
|
log('');
|
|
179
300
|
log(ux.colorize('bold', `=== Push Preview: ${target.label} ===`));
|
|
180
301
|
let instanceHost = target.instanceOrigin;
|
|
@@ -238,6 +359,17 @@ function renderPreview(result, willDelete, target, verbose, partial, log) {
|
|
|
238
359
|
// Split destructive ops by category
|
|
239
360
|
const deleteOps = destructive.filter((op) => op.action === 'delete' || op.action === 'cascade_delete');
|
|
240
361
|
const alwaysDestructive = destructive.filter((op) => op.action === 'truncate' || op.action === 'drop_field' || op.action === 'alter_field');
|
|
362
|
+
// --include/--exclude and --delete interact in a way the preview cannot show
|
|
363
|
+
// on its own: a filtered-out document is absent from the payload, so without
|
|
364
|
+
// the protection the push declares, the sweep would read it as removed. The
|
|
365
|
+
// objects it defines are spared (and the push is refused outright if they
|
|
366
|
+
// cannot be), but anything ELSE missing from the tree is still deleted below,
|
|
367
|
+
// which is worth saying while a filter is active.
|
|
368
|
+
if (willDelete && filteredOutCount > 0 && deleteOps.length > 0) {
|
|
369
|
+
log('');
|
|
370
|
+
log(ux.colorize('yellow', ` Note: ${filteredOutCount} file(s) were filtered out by --include/--exclude, and --delete is on.`));
|
|
371
|
+
log(ux.colorize('yellow', ' What they define is protected from the sweep; the deletes below are objects missing from the tree entirely.'));
|
|
372
|
+
}
|
|
241
373
|
// Show destructive operations (deletes only when --delete, truncates/drop_field always)
|
|
242
374
|
const shownDestructive = [...(willDelete ? deleteOps : []), ...alwaysDestructive];
|
|
243
375
|
if (shownDestructive.length > 0) {
|
|
@@ -300,6 +432,12 @@ export async function confirm(message) {
|
|
|
300
432
|
});
|
|
301
433
|
}
|
|
302
434
|
// ── GUID Sync ───────────────────────────────────────────────────────────────
|
|
435
|
+
/**
|
|
436
|
+
* Ceiling for the `protect_guids` query parameter. The whole request LINE must
|
|
437
|
+
* fit in one nginx buffer (8k by default), and the URL already carries the rest
|
|
438
|
+
* of the push parameters, so stay well inside it.
|
|
439
|
+
*/
|
|
440
|
+
const PROTECT_GUIDS_MAX_CHARS = 4000;
|
|
303
441
|
const GUID_REGEX = /guid\s*=\s*(["'])([^"']*)\1/;
|
|
304
442
|
/**
|
|
305
443
|
* Sync a GUID into a local .xs file. Returns true if the file was modified.
|
|
@@ -341,6 +479,32 @@ function syncGuidToFile(filePath, guid) {
|
|
|
341
479
|
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
342
480
|
return true;
|
|
343
481
|
}
|
|
482
|
+
const CANONICAL_REGEX = /canonical\s*=\s*(["'])([^"']*)\1/;
|
|
483
|
+
/**
|
|
484
|
+
* Sync a server-assigned `canonical` into a local .xs file. Returns true if the
|
|
485
|
+
* file was modified.
|
|
486
|
+
*
|
|
487
|
+
* Like the GUID, a canonical is minted by the instance and the document can only
|
|
488
|
+
* ASK for one: it is unique across every workspace on the instance, so a tree
|
|
489
|
+
* cloned from another workspace names canonicals that are already taken and the
|
|
490
|
+
* import keeps the existing value instead. Without this writeback the local file
|
|
491
|
+
* kept asking for a value the server kept refusing, so every push reported the
|
|
492
|
+
* same objects as changed forever.
|
|
493
|
+
*
|
|
494
|
+
* Unlike the GUID this NEVER inserts a missing line: only the container kinds
|
|
495
|
+
* (api_group, realtime_server, mcp_server/agent/toolset) have the field, and the
|
|
496
|
+
* server omits the key for everything else, so an absent line means "this kind
|
|
497
|
+
* has no canonical" rather than "not yet assigned".
|
|
498
|
+
*/
|
|
499
|
+
function syncCanonicalToFile(filePath, canonical) {
|
|
500
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
501
|
+
const existingMatch = content.match(CANONICAL_REGEX);
|
|
502
|
+
if (!existingMatch || existingMatch[2] === canonical) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
fs.writeFileSync(filePath, content.replace(CANONICAL_REGEX, `canonical = "${canonical}"`), 'utf8');
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
344
508
|
// ── Knowledge Preview Helpers ─────────────────────────────────────────────────
|
|
345
509
|
/**
|
|
346
510
|
* Compute a knowledge change preview: prefer the server's `dry_run` response,
|
|
@@ -398,6 +562,8 @@ export async function executePush(ctx, target, flags) {
|
|
|
398
562
|
// ── Collect and filter .xs files ──────────────────────────────────────
|
|
399
563
|
const allFiles = collectFiles(inputDir);
|
|
400
564
|
const files = applyFilters(allFiles, inputDir, flags.include, flags.exclude, log);
|
|
565
|
+
const filteredOut = describeFilteredOut(allFiles, files, inputDir);
|
|
566
|
+
const filteredOutCount = allFiles.length - files.length;
|
|
401
567
|
const knowledgeOnly = files.length === 0 && (knowledgeObjects.length > 0 || ctx.knowledge !== undefined);
|
|
402
568
|
if (files.length === 0 && !knowledgeOnly) {
|
|
403
569
|
command.error(flags.include || flags.exclude
|
|
@@ -504,6 +670,17 @@ export async function executePush(ctx, target, flags) {
|
|
|
504
670
|
if (target.sourceWorkspaceId) {
|
|
505
671
|
queryParams.set('source_workspace_id', target.sourceWorkspaceId);
|
|
506
672
|
}
|
|
673
|
+
// Tell the server which objects the filter put out of scope, so its delete
|
|
674
|
+
// sweep spares them. Only meaningful with --delete; without it nothing is
|
|
675
|
+
// swept. The parameter travels in the query string (the body is the multidoc
|
|
676
|
+
// itself), so it has a length ceiling: past PROTECT_GUIDS_MAX_CHARS we send
|
|
677
|
+
// nothing and let the verification below refuse the push rather than issue a
|
|
678
|
+
// request the server may truncate or reject.
|
|
679
|
+
const protectableGuids = filteredOut.map((d) => d.guid).filter((g) => typeof g === 'string' && g.length > 0);
|
|
680
|
+
const protectGuidsParam = protectableGuids.join(',');
|
|
681
|
+
if (shouldDelete && protectGuidsParam && protectGuidsParam.length <= PROTECT_GUIDS_MAX_CHARS) {
|
|
682
|
+
queryParams.set('protect_guids', protectGuidsParam);
|
|
683
|
+
}
|
|
507
684
|
// ── Request headers ───────────────────────────────────────────────────
|
|
508
685
|
const requestHeaders = {
|
|
509
686
|
accept: 'application/json',
|
|
@@ -535,7 +712,46 @@ export async function executePush(ctx, target, flags) {
|
|
|
535
712
|
const knowledgeDryRun = await computeKnowledgePreview(ctx.knowledge.listUrl(), knowledgeObjects, ctx.branch, shouldDelete, accessToken, verboseFetch, flags.verbose);
|
|
536
713
|
mergeKnowledgePreview(preview, knowledgeDryRun);
|
|
537
714
|
}
|
|
538
|
-
renderPreview(preview, shouldDelete, target, flags.verbose, isPartial, log);
|
|
715
|
+
renderPreview(preview, shouldDelete, target, flags.verbose, isPartial, log, filteredOutCount);
|
|
716
|
+
// GUARD: --include/--exclude must never cost you the objects it
|
|
717
|
+
// filtered out.
|
|
718
|
+
//
|
|
719
|
+
// A filtered-out document is absent from the payload, so a --delete
|
|
720
|
+
// sweep reads it as removed from the tree and deletes it - the exact
|
|
721
|
+
// opposite of what -e means to the person typing it. The server is
|
|
722
|
+
// told to spare them (protect_guids above), but this checks the
|
|
723
|
+
// SERVER'S OWN preview rather than assuming that worked: an older
|
|
724
|
+
// instance ignores the parameter, a document that has never been
|
|
725
|
+
// pushed has no GUID to protect, and a very large filter exceeds what
|
|
726
|
+
// the query string can carry. In every one of those cases the push is
|
|
727
|
+
// refused rather than allowed to delete.
|
|
728
|
+
if (shouldDelete) {
|
|
729
|
+
const endangered = deletesHittingFilteredOut(preview.operations, filteredOut);
|
|
730
|
+
if (endangered.length > 0) {
|
|
731
|
+
log('');
|
|
732
|
+
log(ux.colorize('red', '--- Refusing to push ---'));
|
|
733
|
+
log('');
|
|
734
|
+
log(` --delete would remove ${endangered.length} object(s) that --include/--exclude filtered out:`);
|
|
735
|
+
log('');
|
|
736
|
+
for (const hit of endangered.slice(0, 10)) {
|
|
737
|
+
log(` ${ux.colorize('red', 'DELETE'.padEnd(8))} ${hit.type.padEnd(18)} ${hit.name} ${ux.colorize('dim', `(${hit.relPath})`)}`);
|
|
738
|
+
}
|
|
739
|
+
if (endangered.length > 10) {
|
|
740
|
+
log(ux.colorize('dim', ` ... and ${endangered.length - 10} more`));
|
|
741
|
+
}
|
|
742
|
+
log('');
|
|
743
|
+
const unprotectable = filteredOut.filter((d) => !d.guid).length;
|
|
744
|
+
if (unprotectable > 0) {
|
|
745
|
+
log(ux.colorize('dim', ` ${unprotectable} filtered-out document(s) have no guid yet, so the server cannot be told to spare them.`));
|
|
746
|
+
}
|
|
747
|
+
if (protectGuidsParam.length > PROTECT_GUIDS_MAX_CHARS) {
|
|
748
|
+
log(ux.colorize('dim', ' Too many filtered-out documents to declare in one request.'));
|
|
749
|
+
}
|
|
750
|
+
log(ux.colorize('dim', ' This instance may also predate protected pushes.'));
|
|
751
|
+
log('');
|
|
752
|
+
command.error('Re-run without --delete, or without the --include/--exclude filter.');
|
|
753
|
+
}
|
|
754
|
+
}
|
|
539
755
|
// Check for bad cross-references using dry-run operations to avoid false positives
|
|
540
756
|
const badRefs = checkReferences(documentEntries, preview.operations);
|
|
541
757
|
if (badRefs.length > 0) {
|
|
@@ -773,6 +989,7 @@ export async function executePush(ctx, target, flags) {
|
|
|
773
989
|
}
|
|
774
990
|
}
|
|
775
991
|
let updatedCount = 0;
|
|
992
|
+
let canonicalCount = 0;
|
|
776
993
|
for (const entry of guidMap) {
|
|
777
994
|
if (!entry.guid)
|
|
778
995
|
continue;
|
|
@@ -799,10 +1016,23 @@ export async function executePush(ctx, target, flags) {
|
|
|
799
1016
|
catch (error) {
|
|
800
1017
|
command.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
|
|
801
1018
|
}
|
|
1019
|
+
if (entry.canonical) {
|
|
1020
|
+
try {
|
|
1021
|
+
const updated = syncCanonicalToFile(filePath, entry.canonical);
|
|
1022
|
+
if (updated)
|
|
1023
|
+
canonicalCount++;
|
|
1024
|
+
}
|
|
1025
|
+
catch (error) {
|
|
1026
|
+
command.warn(`Failed to sync canonical to ${filePath}: ${error.message}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
802
1029
|
}
|
|
803
1030
|
if (updatedCount > 0) {
|
|
804
1031
|
log(`Synced ${updatedCount} GUIDs to local files`);
|
|
805
1032
|
}
|
|
1033
|
+
if (canonicalCount > 0) {
|
|
1034
|
+
log(`Synced ${canonicalCount} canonicals to local files (the instance assigned its own)`);
|
|
1035
|
+
}
|
|
806
1036
|
}
|
|
807
1037
|
pushedDocCount = multidoc.split('\n---\n').length;
|
|
808
1038
|
}
|