@xano/cli 1.2.0 → 1.3.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 +112 -3
- package/dist/base-command.d.ts +43 -36
- package/dist/base-command.js +223 -160
- package/dist/commands/auth/index.d.ts +1 -1
- package/dist/commands/auth/index.js +7 -7
- package/dist/commands/branch/create/index.d.ts +1 -1
- package/dist/commands/branch/create/index.js +1 -1
- 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 +20 -0
- package/dist/commands/ephemeral/impersonate/index.js +120 -0
- package/dist/commands/ephemeral/list/index.d.ts +19 -0
- package/dist/commands/ephemeral/list/index.js +123 -0
- package/dist/commands/ephemeral/pull/index.d.ts +19 -0
- package/dist/commands/ephemeral/pull/index.js +163 -0
- package/dist/commands/ephemeral/push/index.d.ts +35 -0
- package/dist/commands/ephemeral/push/index.js +223 -0
- package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +20 -0
- package/dist/commands/ephemeral/static_host/build/delete/index.js +121 -0
- package/dist/commands/ephemeral/static_host/build/get/index.d.ts +18 -0
- package/dist/commands/ephemeral/static_host/build/get/index.js +137 -0
- package/dist/commands/ephemeral/static_host/build/list/index.d.ts +18 -0
- package/dist/commands/ephemeral/static_host/build/list/index.js +137 -0
- package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +53 -0
- package/dist/commands/ephemeral/static_host/build/pull/index.js +308 -0
- package/dist/commands/ephemeral/static_host/build/push/index.d.ts +24 -0
- package/dist/commands/ephemeral/static_host/build/push/index.js +250 -0
- package/dist/commands/ephemeral/static_host/create/index.d.ts +18 -0
- package/dist/commands/ephemeral/static_host/create/index.js +92 -0
- package/dist/commands/ephemeral/static_host/deploy/index.d.ts +19 -0
- package/dist/commands/ephemeral/static_host/deploy/index.js +112 -0
- package/dist/commands/ephemeral/static_host/edit/index.d.ts +24 -0
- package/dist/commands/ephemeral/static_host/edit/index.js +158 -0
- package/dist/commands/ephemeral/static_host/get/index.d.ts +19 -0
- package/dist/commands/ephemeral/static_host/get/index.js +101 -0
- package/dist/commands/ephemeral/static_host/list/index.d.ts +17 -0
- package/dist/commands/ephemeral/static_host/list/index.js +132 -0
- package/dist/commands/flatten/index.d.ts +34 -0
- package/dist/commands/flatten/index.js +140 -0
- package/dist/commands/function/create/index.js +1 -1
- package/dist/commands/function/edit/index.js +26 -32
- package/dist/commands/function/get/index.js +2 -3
- package/dist/commands/function/run/index.d.ts +1 -0
- package/dist/commands/function/run/index.js +19 -6
- package/dist/commands/profile/create/index.d.ts +1 -1
- package/dist/commands/profile/create/index.js +7 -7
- package/dist/commands/profile/me/index.js +1 -1
- package/dist/commands/profile/use/index.js +0 -4
- package/dist/commands/profile/wizard/index.d.ts +1 -1
- package/dist/commands/profile/wizard/index.js +12 -10
- package/dist/commands/profile/workspace/set/index.js +1 -1
- package/dist/commands/release/edit/index.js +2 -2
- package/dist/commands/release/export/index.js +1 -2
- package/dist/commands/release/import/index.js +1 -1
- package/dist/commands/release/pull/index.d.ts +0 -6
- package/dist/commands/release/pull/index.js +13 -99
- package/dist/commands/release/push/index.js +2 -2
- package/dist/commands/sandbox/env/get_all/index.js +1 -1
- package/dist/commands/sandbox/env/set_all/index.js +1 -1
- package/dist/commands/sandbox/get/index.js +1 -1
- package/dist/commands/sandbox/license/get/index.js +1 -1
- package/dist/commands/sandbox/license/set/index.js +1 -1
- package/dist/commands/sandbox/pull/index.d.ts +0 -1
- package/dist/commands/sandbox/pull/index.js +13 -81
- package/dist/commands/sandbox/push/index.d.ts +1 -1
- package/dist/commands/sandbox/push/index.js +5 -5
- package/dist/commands/static_host/build/create/index.d.ts +1 -1
- package/dist/commands/static_host/build/create/index.js +2 -2
- package/dist/commands/static_host/build/pull/index.js +1 -1
- package/dist/commands/static_host/build/push/index.js +1 -1
- package/dist/commands/static_host/edit/index.js +1 -1
- package/dist/commands/tenant/backup/export/index.js +1 -2
- package/dist/commands/tenant/backup/import/index.js +1 -1
- package/dist/commands/tenant/cluster/create/index.js +1 -1
- package/dist/commands/tenant/cluster/license/get/index.js +1 -1
- package/dist/commands/tenant/cluster/license/set/index.js +1 -1
- package/dist/commands/tenant/create/index.d.ts +1 -1
- package/dist/commands/tenant/create/index.js +6 -6
- package/dist/commands/tenant/deploy_platform/index.js +1 -1
- package/dist/commands/tenant/edit/index.js +7 -7
- package/dist/commands/tenant/env/get_all/index.js +1 -1
- package/dist/commands/tenant/env/set_all/index.js +1 -1
- package/dist/commands/tenant/impersonate/index.d.ts +1 -1
- package/dist/commands/tenant/impersonate/index.js +14 -14
- package/dist/commands/tenant/license/get/index.js +1 -1
- package/dist/commands/tenant/license/set/index.js +1 -1
- package/dist/commands/tenant/pull/index.d.ts +0 -6
- package/dist/commands/tenant/pull/index.js +14 -100
- package/dist/commands/workspace/git/pull/index.d.ts +4 -4
- package/dist/commands/workspace/git/pull/index.js +158 -97
- package/dist/commands/workspace/pull/index.d.ts +1 -7
- package/dist/commands/workspace/pull/index.js +17 -103
- package/dist/help.js +4 -2
- package/dist/update-check.d.ts +1 -1
- package/dist/update-check.js +1 -1
- package/dist/utils/document-parser.d.ts +116 -0
- package/dist/utils/document-parser.js +311 -4
- package/dist/utils/flatten.d.ts +36 -0
- package/dist/utils/flatten.js +68 -0
- package/dist/utils/knowledge-sync.d.ts +1 -0
- package/dist/utils/knowledge-sync.js +11 -6
- package/dist/utils/microservice-wait.d.ts +53 -0
- package/dist/utils/microservice-wait.js +84 -0
- package/dist/utils/multidoc-push.d.ts +88 -0
- package/dist/utils/multidoc-push.js +378 -15
- package/dist/utils/paging.d.ts +2 -1
- package/dist/utils/paging.js +0 -1
- package/dist/utils/reference-checker.js +14 -3
- package/oclif.manifest.json +5264 -3170
- package/package.json +10 -1
|
@@ -45,6 +45,25 @@ export function parseDocument(content) {
|
|
|
45
45
|
if (apiGroupMatch) {
|
|
46
46
|
apiGroup = apiGroupMatch[1];
|
|
47
47
|
}
|
|
48
|
+
// Extract channel if present (e.g., channel = "rooms/{room_id}").
|
|
49
|
+
// Realtime v2 messages reference their owning channel by PATH, which is what
|
|
50
|
+
// nests them under it on disk.
|
|
51
|
+
let channel;
|
|
52
|
+
const channelMatch = content.match(/^\s*channel\s*=\s*"([^"]*)"/m);
|
|
53
|
+
if (channelMatch) {
|
|
54
|
+
channel = channelMatch[1];
|
|
55
|
+
}
|
|
56
|
+
// Extract realtime_server if present (e.g., realtime_server = "chat").
|
|
57
|
+
// Realtime v2 channels/messages reference their owning realtime_server by NAME
|
|
58
|
+
// via a top-level `realtime_server = "..."`, which is what nests them under it
|
|
59
|
+
// on disk (mirrors how a message names its channel via `channel = "..."`).
|
|
60
|
+
// The keyword is `realtime_server` (renamed from a bare `server` to match the
|
|
61
|
+
// api_group/mcp_server convention — reference the container by its full type).
|
|
62
|
+
let server;
|
|
63
|
+
const serverMatch = content.match(/^\s*realtime_server\s*=\s*"([^"]*)"/m);
|
|
64
|
+
if (serverMatch) {
|
|
65
|
+
server = serverMatch[1];
|
|
66
|
+
}
|
|
48
67
|
// Extract canonical if present (e.g., canonical = "abc123")
|
|
49
68
|
let canonical;
|
|
50
69
|
const canonicalMatch = content.match(/canonical\s*=\s*"([^"]*)"/);
|
|
@@ -57,7 +76,237 @@ export function parseDocument(content) {
|
|
|
57
76
|
if (guidMatch) {
|
|
58
77
|
guid = guidMatch[1];
|
|
59
78
|
}
|
|
60
|
-
return { apiGroup, canonical, content, guid, name, type, verb };
|
|
79
|
+
return { apiGroup, canonical, channel, content, guid, name, server, type, verb };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Map a realtime v2 channel path onto directory segments.
|
|
83
|
+
*
|
|
84
|
+
* A channel name IS a path template — "rooms/{room_id}" — so the directory tree
|
|
85
|
+
* mirrors it rather than flattening it. That is the whole reason messages nest
|
|
86
|
+
* under channels: the tree should show the structure, and
|
|
87
|
+
* "channel/rooms/[room_id]/post.xs" says immediately that `post` lives on a
|
|
88
|
+
* parameterized rooms channel.
|
|
89
|
+
*
|
|
90
|
+
* "rooms" -> ["rooms"]
|
|
91
|
+
* "rooms/{room_id}" -> ["rooms", "[room_id]"]
|
|
92
|
+
* "org/{org_id}/room/{id}" -> ["org", "[org_id]", "room", "[id]"]
|
|
93
|
+
*
|
|
94
|
+
* Braces become brackets because "{" and "}" are awkward in shells, while "["
|
|
95
|
+
* and "]" are legal on every filesystem. They are still glob character classes,
|
|
96
|
+
* so scripting against a checkout needs the path quoted.
|
|
97
|
+
*
|
|
98
|
+
* Escaping the braces rather than dropping them is what keeps a parameterized
|
|
99
|
+
* channel distinct from a literal one: without it, "rooms/{room_id}" and a
|
|
100
|
+
* literal channel named "rooms/room_id" collide on the same directory.
|
|
101
|
+
*
|
|
102
|
+
* Mirrors Vfs::channelSegment() on the server.
|
|
103
|
+
*/
|
|
104
|
+
export function channelPathSegments(name, snakeCaseFn) {
|
|
105
|
+
// A parameter keeps the author's name verbatim so it matches the `input`
|
|
106
|
+
// block; only the delimiters are swapped.
|
|
107
|
+
const isParam = /^\{.*\}$/;
|
|
108
|
+
return name.split('/').map((segment) => (isParam.test(segment) ? `[${segment.slice(1, -1)}]` : snakeCaseFn(segment)));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Build a map of realtime v2 channel path -> owning realtime_server name.
|
|
112
|
+
*
|
|
113
|
+
* This is the cross-document lookup the on-disk nesting needs: a `message`
|
|
114
|
+
* document names its channel (`channel = "..."`), while a `channel` document
|
|
115
|
+
* names its server (`realtime_server = "..."`). To place a message under
|
|
116
|
+
* `realtime/server/<server>/channel/<path>/...` we resolve the message's channel
|
|
117
|
+
* to that channel's server — which the channel document carries. (Messages now
|
|
118
|
+
* also carry `realtime_server` directly, but resolving via the channel keeps a
|
|
119
|
+
* single source of truth for the channel→server mapping.)
|
|
120
|
+
*
|
|
121
|
+
* Because every pull path parses the WHOLE multidoc into memory before writing,
|
|
122
|
+
* we can build this map from the `channel` docs in the same batch (a two-pass
|
|
123
|
+
* resolve), keyed by the channel's own name (its path). A channel with no
|
|
124
|
+
* `realtime_server` (e.g. a pre-server export) simply isn't in the map, and
|
|
125
|
+
* callers fall back accordingly.
|
|
126
|
+
*/
|
|
127
|
+
export function buildChannelServerResolver(documents) {
|
|
128
|
+
const channelToServer = new Map();
|
|
129
|
+
for (const doc of documents) {
|
|
130
|
+
if (doc.type === 'channel' && doc.server) {
|
|
131
|
+
channelToServer.set(doc.name, doc.server);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return (channelName) => channelToServer.get(channelName);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Sanitize a document name for use as a single on-disk filename segment.
|
|
138
|
+
* Strips quotes and snake_cases the rest. Mirrors the per-command
|
|
139
|
+
* `sanitizeFilename` the pull commands used to each define privately.
|
|
140
|
+
*/
|
|
141
|
+
export function sanitizeDocumentName(name, snakeCaseFn) {
|
|
142
|
+
return snakeCaseFn(name.replaceAll('"', ''));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Resolve where a parsed document lands on disk — the SINGLE source of truth for
|
|
146
|
+
* the pull layout, shared by every pull command (workspace, tenant, release,
|
|
147
|
+
* sandbox, ephemeral). Previously each command inlined a ~130-line copy of this
|
|
148
|
+
* decision; the copies drifted (the realtime v2 realtime_server/channel/message
|
|
149
|
+
* branches existed only in workspace/tenant), so an ephemeral pull dumped v2
|
|
150
|
+
* objects into flat top-level channel/, message/ and realtime_server/ folders —
|
|
151
|
+
* detaching messages from their channels and risking name collisions (message
|
|
152
|
+
* names are unique only WITHIN a channel).
|
|
153
|
+
*
|
|
154
|
+
* @param doc - the parsed document
|
|
155
|
+
* @param outputDir - the resolved pull output root
|
|
156
|
+
* @param deps - injected dependencies
|
|
157
|
+
* @param deps.getApiGroupFolder - resolves an api_group name to its unique folder
|
|
158
|
+
* @param deps.getChannelServer - resolves a v2 channel path to its owning realtime_server
|
|
159
|
+
* @param deps.join - the path.join implementation (injectable for testing)
|
|
160
|
+
* @param deps.snakeCase - the snakeCase implementation the caller uses
|
|
161
|
+
*/
|
|
162
|
+
export function resolveDocumentPath(doc, outputDir, deps) {
|
|
163
|
+
const { getApiGroupFolder, getChannelServer, join, snakeCase } = deps;
|
|
164
|
+
const sanitize = (name) => sanitizeDocumentName(name, snakeCase);
|
|
165
|
+
if (doc.type === 'workspace') {
|
|
166
|
+
// workspace → workspace/{name}.xs
|
|
167
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'workspace') };
|
|
168
|
+
}
|
|
169
|
+
if (doc.type === 'workspace_trigger' || doc.type === 'error_trigger') {
|
|
170
|
+
// workspace_trigger / error_trigger → workspace/trigger/{name}.xs
|
|
171
|
+
// (error_trigger is a singleton, colocated with workspace triggers)
|
|
172
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'workspace', 'trigger') };
|
|
173
|
+
}
|
|
174
|
+
if (doc.type === 'agent') {
|
|
175
|
+
// agent → ai/agent/{name}.xs
|
|
176
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'agent') };
|
|
177
|
+
}
|
|
178
|
+
if (doc.type === 'mcp_server') {
|
|
179
|
+
// mcp_server → ai/mcp_server/{name}.xs
|
|
180
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'mcp_server') };
|
|
181
|
+
}
|
|
182
|
+
if (doc.type === 'tool') {
|
|
183
|
+
// tool → ai/tool/{name}.xs
|
|
184
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'tool') };
|
|
185
|
+
}
|
|
186
|
+
if (doc.type === 'agent_trigger') {
|
|
187
|
+
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
188
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'agent', 'trigger') };
|
|
189
|
+
}
|
|
190
|
+
if (doc.type === 'mcp_server_trigger') {
|
|
191
|
+
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
192
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'mcp_server', 'trigger') };
|
|
193
|
+
}
|
|
194
|
+
if (doc.type === 'table_trigger') {
|
|
195
|
+
// table_trigger → table/trigger/{name}.xs
|
|
196
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'table', 'trigger') };
|
|
197
|
+
}
|
|
198
|
+
if (doc.type === 'realtime_channel') {
|
|
199
|
+
// Realtime v1: realtime_channel → realtime/channel/{name}.xs
|
|
200
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'channel') };
|
|
201
|
+
}
|
|
202
|
+
if (doc.type === 'realtime_trigger') {
|
|
203
|
+
// Realtime v1: realtime_trigger → realtime/trigger/{name}.xs
|
|
204
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'trigger') };
|
|
205
|
+
}
|
|
206
|
+
if (doc.type === 'realtime_server_trigger') {
|
|
207
|
+
// Realtime v2. A realtime_server's connect/disconnect trigger. It references its
|
|
208
|
+
// owning server by name via `realtime_server = "..."`, so it nests under that
|
|
209
|
+
// server's folder as `<server>/trigger/`, mirroring how the server's own document
|
|
210
|
+
// and its channels already nest under realtime/server/<server>/ (DEV-7712). Keeping
|
|
211
|
+
// it there means every realtime document stays under realtime/ instead of landing at
|
|
212
|
+
// the output root. A server-less doc (a malformed/legacy export) falls back to a flat
|
|
213
|
+
// realtime/server_trigger/ under realtime/ rather than the repo root.
|
|
214
|
+
return {
|
|
215
|
+
baseName: sanitize(doc.name),
|
|
216
|
+
typeDir: doc.server
|
|
217
|
+
? join(outputDir, 'realtime', 'server', sanitize(doc.server), 'trigger')
|
|
218
|
+
: join(outputDir, 'realtime', 'server_trigger'),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (doc.type === 'channel_trigger') {
|
|
222
|
+
// Realtime v2. A channel's join/leave/deliver trigger. It references BOTH its owning
|
|
223
|
+
// server (`realtime_server = "..."`) and its channel (`channel = "..."`), so it nests
|
|
224
|
+
// beside that channel as `<server>/channel/<channel>/trigger/` — the same path the
|
|
225
|
+
// channel's own document and messages already use (DEV-7712). Falls back gracefully:
|
|
226
|
+
// channel but no resolvable server → the legacy flat channel/<path>/trigger/ layout;
|
|
227
|
+
// neither → realtime/channel_trigger/ under realtime/ (never the output root).
|
|
228
|
+
if (doc.server && doc.channel) {
|
|
229
|
+
return {
|
|
230
|
+
baseName: sanitize(doc.name),
|
|
231
|
+
typeDir: join(outputDir, 'realtime', 'server', sanitize(doc.server), 'channel', snakeCase(doc.channel), 'trigger'),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (doc.channel) {
|
|
235
|
+
return {
|
|
236
|
+
baseName: sanitize(doc.name),
|
|
237
|
+
typeDir: join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase), 'trigger'),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'channel_trigger') };
|
|
241
|
+
}
|
|
242
|
+
if (doc.type === 'realtime_server') {
|
|
243
|
+
// Realtime v2. The realtime_server is the top-level container that owns
|
|
244
|
+
// channels (which own messages). Its own document is named after itself
|
|
245
|
+
// inside its name directory, mirroring api_group (api/<group>/<group>.xs).
|
|
246
|
+
// realtime_server "chat" → realtime/server/chat/chat.xs
|
|
247
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'server', sanitize(doc.name)) };
|
|
248
|
+
}
|
|
249
|
+
if (doc.type === 'channel') {
|
|
250
|
+
// Realtime v2. The channel owns a directory named after itself (the full
|
|
251
|
+
// channel name, snake_cased into a single flat segment), and its messages
|
|
252
|
+
// live in a message/ subfolder inside it. It nests under its owning
|
|
253
|
+
// realtime_server (from `realtime_server = "..."`):
|
|
254
|
+
// channel "rooms/{room_id}" (server "chat")
|
|
255
|
+
// → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
|
|
256
|
+
//
|
|
257
|
+
// A channel with no resolvable server (e.g. a pre-server export) falls back
|
|
258
|
+
// to the legacy flat channel/<path>/_channel.xs layout.
|
|
259
|
+
if (doc.server) {
|
|
260
|
+
return {
|
|
261
|
+
baseName: snakeCase(doc.name),
|
|
262
|
+
typeDir: join(outputDir, 'realtime', 'server', sanitize(doc.server), 'channel', snakeCase(doc.name)),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return { baseName: '_channel', typeDir: join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase)) };
|
|
266
|
+
}
|
|
267
|
+
if (doc.type === 'message' && doc.channel) {
|
|
268
|
+
// Realtime v2 message → nests in a message/ subfolder under its channel,
|
|
269
|
+
// under that channel's server (resolved via the two-pass channel→server map).
|
|
270
|
+
// message "post" on channel "rooms/{room_id}" (server "chat")
|
|
271
|
+
// → realtime/server/chat/channel/rooms_room_id/message/post.xs
|
|
272
|
+
//
|
|
273
|
+
// Nesting matters beyond tidiness: message names are unique only WITHIN a
|
|
274
|
+
// channel, so a flat message/ directory would collide when two channels
|
|
275
|
+
// both define e.g. "say". A channel whose server can't be resolved falls
|
|
276
|
+
// back to the legacy flat channel/ layout.
|
|
277
|
+
const messageServer = getChannelServer(doc.channel);
|
|
278
|
+
return {
|
|
279
|
+
baseName: sanitize(doc.name),
|
|
280
|
+
typeDir: messageServer
|
|
281
|
+
? join(outputDir, 'realtime', 'server', sanitize(messageServer), 'channel', snakeCase(doc.channel), 'message')
|
|
282
|
+
: join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase)),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (doc.type === 'api_group') {
|
|
286
|
+
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
287
|
+
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'api', getApiGroupFolder(doc.name)) };
|
|
288
|
+
}
|
|
289
|
+
if (doc.type === 'query' && doc.apiGroup) {
|
|
290
|
+
// query in group "test" → api/{resolved_folder}/{folders}/{query_name}[_verb].xs
|
|
291
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
292
|
+
const nameParts = doc.name.split('/');
|
|
293
|
+
const leafName = nameParts.pop();
|
|
294
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
295
|
+
const baseName = sanitize(leafName);
|
|
296
|
+
return {
|
|
297
|
+
baseName: doc.verb ? `${baseName}_${doc.verb}` : baseName,
|
|
298
|
+
typeDir: join(outputDir, 'api', groupFolder, ...folderParts),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// Default: split folder path from name
|
|
302
|
+
const nameParts = doc.name.split('/');
|
|
303
|
+
const leafName = nameParts.pop();
|
|
304
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
305
|
+
const baseName = sanitize(leafName);
|
|
306
|
+
return {
|
|
307
|
+
baseName: doc.verb ? `${baseName}_${doc.verb}` : baseName,
|
|
308
|
+
typeDir: join(outputDir, doc.type, ...folderParts),
|
|
309
|
+
};
|
|
61
310
|
}
|
|
62
311
|
/**
|
|
63
312
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
@@ -101,9 +350,7 @@ export function buildApiGroupFolderResolver(documents, snakeCaseFn) {
|
|
|
101
350
|
apiGroupFolderMap.set(names[i], `${folder}_${i + 1}`);
|
|
102
351
|
}
|
|
103
352
|
}
|
|
104
|
-
return (groupName) =>
|
|
105
|
-
return apiGroupFolderMap.get(groupName) ?? snakeCaseFn(groupName);
|
|
106
|
-
};
|
|
353
|
+
return (groupName) => apiGroupFolderMap.get(groupName) ?? snakeCaseFn(groupName);
|
|
107
354
|
}
|
|
108
355
|
/**
|
|
109
356
|
* Find local .xs files that contain a specific GUID.
|
|
@@ -112,3 +359,63 @@ export function buildApiGroupFolderResolver(documents, snakeCaseFn) {
|
|
|
112
359
|
export function findFilesWithGuid(entries, guid) {
|
|
113
360
|
return entries.filter((e) => e.content.includes(guid)).map((e) => e.filePath);
|
|
114
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Split a multi-document XanoScript blob (documents joined by `\n---\n`) into
|
|
364
|
+
* parsed documents, skipping empties and any fragment that fails to parse.
|
|
365
|
+
*
|
|
366
|
+
* This is the same split the pull commands apply to the server's multidoc
|
|
367
|
+
* response. A hand-authored bundle is one file of many `---`-separated
|
|
368
|
+
* documents; this turns it back into the per-document units every downstream
|
|
369
|
+
* step (placement, GUID writeback, partial-diff matching) assumes.
|
|
370
|
+
*/
|
|
371
|
+
export function splitMultidoc(blob) {
|
|
372
|
+
const documents = [];
|
|
373
|
+
for (const raw of blob.split('\n---\n')) {
|
|
374
|
+
const trimmed = raw.trim();
|
|
375
|
+
if (!trimmed)
|
|
376
|
+
continue;
|
|
377
|
+
const parsed = parseDocument(trimmed);
|
|
378
|
+
if (parsed)
|
|
379
|
+
documents.push(parsed);
|
|
380
|
+
}
|
|
381
|
+
return documents;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Compute where each document lands on disk — a PURE function (no filesystem)
|
|
385
|
+
* mirroring the split-and-place loop the pull commands run. It resolves each
|
|
386
|
+
* document via `resolveDocumentPath`, appends `.xs`, and disambiguates
|
|
387
|
+
* collisions within a directory with a `_N` suffix (the second file named
|
|
388
|
+
* `say.xs` in a dir becomes `say_2.xs`), exactly as the pull writer does.
|
|
389
|
+
*
|
|
390
|
+
* Kept filesystem-free so it can be unit-tested and reused by both the pull
|
|
391
|
+
* commands and `flatten` without duplicating the placement rules.
|
|
392
|
+
*/
|
|
393
|
+
export function placeDocuments(documents, deps) {
|
|
394
|
+
const { join, relative, snakeCase } = deps;
|
|
395
|
+
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
396
|
+
const getChannelServer = buildChannelServerResolver(documents);
|
|
397
|
+
// Per-directory filename counters, so duplicate leaf names get a _N suffix.
|
|
398
|
+
const filenameCounters = new Map();
|
|
399
|
+
const placed = [];
|
|
400
|
+
for (const doc of documents) {
|
|
401
|
+
const { baseName, typeDir } = resolveDocumentPath(doc, '', {
|
|
402
|
+
getApiGroupFolder,
|
|
403
|
+
getChannelServer,
|
|
404
|
+
join,
|
|
405
|
+
snakeCase,
|
|
406
|
+
});
|
|
407
|
+
const dirKey = typeDir;
|
|
408
|
+
if (!filenameCounters.has(dirKey))
|
|
409
|
+
filenameCounters.set(dirKey, new Map());
|
|
410
|
+
const typeCounters = filenameCounters.get(dirKey);
|
|
411
|
+
const count = typeCounters.get(baseName) || 0;
|
|
412
|
+
typeCounters.set(baseName, count + 1);
|
|
413
|
+
const filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
|
|
414
|
+
const abs = join(typeDir, filename);
|
|
415
|
+
// typeDir was built off an empty root, so `abs` is already root-relative;
|
|
416
|
+
// normalize any leading separator via relative() for a clean POSIX path.
|
|
417
|
+
const relPath = relative('', abs);
|
|
418
|
+
placed.push({ content: doc.content, relPath });
|
|
419
|
+
}
|
|
420
|
+
return placed;
|
|
421
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Outcome of flattening one bundle file. */
|
|
2
|
+
export interface FlattenResult {
|
|
3
|
+
/** True when the original bundle was removed. */
|
|
4
|
+
removedSource: boolean;
|
|
5
|
+
/** Why the file was skipped, if it was (empty / single-document). */
|
|
6
|
+
skipped?: 'empty' | 'single';
|
|
7
|
+
/** Absolute paths written (or that would be written, in dry-run). */
|
|
8
|
+
written: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface FlattenOptions {
|
|
11
|
+
/** Preview only — compute the plan and write nothing, delete nothing. */
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
/** Overwrite existing destination files instead of throwing on a clash. */
|
|
14
|
+
force?: boolean;
|
|
15
|
+
/** Keep the original bundle file (default: delete after a successful split). */
|
|
16
|
+
keepSource?: boolean;
|
|
17
|
+
/** Optional line logger (e.g. command.log) for a per-file plan. */
|
|
18
|
+
log?: (msg: string) => void;
|
|
19
|
+
/** Output root (default: the bundle file's own directory). */
|
|
20
|
+
outputDir?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Split ONE multi-document `.xs` bundle (documents joined by `---`) into the
|
|
24
|
+
* standard per-document tree — the exact layout `pull` produces — using the
|
|
25
|
+
* shared `placeDocuments` placement so the two never drift.
|
|
26
|
+
*
|
|
27
|
+
* Filesystem-touching, but self-contained: it reads the bundle, resolves every
|
|
28
|
+
* document's destination, checks for collisions, writes, and (unless
|
|
29
|
+
* `keepSource`) removes the original. Throws on a destination collision when
|
|
30
|
+
* `force` is not set, and never deletes the source when a written document
|
|
31
|
+
* landed on the source's own path.
|
|
32
|
+
*
|
|
33
|
+
* Shared by the `flatten` command and the push-time prompt, so a user who
|
|
34
|
+
* confirms "flatten now?" gets byte-identical output to running `xano flatten`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function flattenBundleFile(bundleFile: string, opts?: FlattenOptions): FlattenResult;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import snakeCase from 'lodash.snakecase';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { placeDocuments, splitMultidoc } from './document-parser.js';
|
|
5
|
+
/**
|
|
6
|
+
* Split ONE multi-document `.xs` bundle (documents joined by `---`) into the
|
|
7
|
+
* standard per-document tree — the exact layout `pull` produces — using the
|
|
8
|
+
* shared `placeDocuments` placement so the two never drift.
|
|
9
|
+
*
|
|
10
|
+
* Filesystem-touching, but self-contained: it reads the bundle, resolves every
|
|
11
|
+
* document's destination, checks for collisions, writes, and (unless
|
|
12
|
+
* `keepSource`) removes the original. Throws on a destination collision when
|
|
13
|
+
* `force` is not set, and never deletes the source when a written document
|
|
14
|
+
* landed on the source's own path.
|
|
15
|
+
*
|
|
16
|
+
* Shared by the `flatten` command and the push-time prompt, so a user who
|
|
17
|
+
* confirms "flatten now?" gets byte-identical output to running `xano flatten`.
|
|
18
|
+
*/
|
|
19
|
+
export function flattenBundleFile(bundleFile, opts = {}) {
|
|
20
|
+
const { dryRun = false, force = false, keepSource = false, log } = opts;
|
|
21
|
+
const content = fs.readFileSync(bundleFile, 'utf8').trim();
|
|
22
|
+
const documents = splitMultidoc(content);
|
|
23
|
+
if (documents.length === 0)
|
|
24
|
+
return { removedSource: false, skipped: 'empty', written: [] };
|
|
25
|
+
if (documents.length === 1)
|
|
26
|
+
return { removedSource: false, skipped: 'single', written: [] };
|
|
27
|
+
const outputDir = opts.outputDir ? resolve(opts.outputDir) : dirname(bundleFile);
|
|
28
|
+
const placed = placeDocuments(documents, { join, relative, snakeCase });
|
|
29
|
+
// Pre-check destination collisions (unless --force / dry-run). A written file
|
|
30
|
+
// landing on the source's own path is not a clash — it replaces the bundle.
|
|
31
|
+
if (!dryRun && !force) {
|
|
32
|
+
const clashes = placed
|
|
33
|
+
.map((p) => join(outputDir, p.relPath))
|
|
34
|
+
.filter((abs) => fs.existsSync(abs) && resolve(abs) !== resolve(bundleFile));
|
|
35
|
+
if (clashes.length > 0) {
|
|
36
|
+
throw new Error(`Destination file(s) already exist (use --force to overwrite):\n` +
|
|
37
|
+
clashes.map((c) => ` ${relative(process.cwd(), c) || c}`).join('\n'));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const written = [];
|
|
41
|
+
for (const p of placed) {
|
|
42
|
+
const abs = join(outputDir, p.relPath);
|
|
43
|
+
if (log)
|
|
44
|
+
log(` WRITE ${relative(process.cwd(), abs) || p.relPath}`);
|
|
45
|
+
if (!dryRun) {
|
|
46
|
+
fs.mkdirSync(dirname(abs), { recursive: true });
|
|
47
|
+
fs.writeFileSync(abs, p.content.endsWith('\n') ? p.content : `${p.content}\n`, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
written.push(abs);
|
|
50
|
+
}
|
|
51
|
+
// Remove the original bundle unless asked to keep it, and never when a split
|
|
52
|
+
// document was written over the source's own path.
|
|
53
|
+
let removedSource = false;
|
|
54
|
+
if (!dryRun && !keepSource) {
|
|
55
|
+
const wroteOverSource = placed.some((p) => resolve(join(outputDir, p.relPath)) === resolve(bundleFile));
|
|
56
|
+
if (wroteOverSource) {
|
|
57
|
+
if (log)
|
|
58
|
+
log(` (source ${basename(bundleFile)} was overwritten by a split document; not deleting)`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
fs.rmSync(bundleFile);
|
|
62
|
+
removedSource = true;
|
|
63
|
+
if (log)
|
|
64
|
+
log(` removed ${relative(process.cwd(), bundleFile) || bundleFile}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { removedSource, written };
|
|
68
|
+
}
|
|
@@ -17,6 +17,7 @@ export interface KnowledgeObject {
|
|
|
17
17
|
mode?: string;
|
|
18
18
|
name: string;
|
|
19
19
|
scope?: string;
|
|
20
|
+
tag?: string[];
|
|
20
21
|
}
|
|
21
22
|
/** A locally-collected object that also remembers its primary file on disk (for GUID writeback). */
|
|
22
23
|
export interface LocalKnowledgeObject extends KnowledgeObject {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable camelcase -- knowledge_type / dry_run / guid_map are external Metadata API field names */
|
|
2
1
|
import * as yaml from 'js-yaml';
|
|
3
2
|
import snakeCase from 'lodash.snakecase';
|
|
4
3
|
import { minimatch } from 'minimatch';
|
|
@@ -12,7 +11,7 @@ import { dirname, join, relative, sep } from 'node:path';
|
|
|
12
11
|
// let the server dictate file paths.
|
|
13
12
|
export const KNOWLEDGE_DIR = 'knowledge';
|
|
14
13
|
// ── Frontmatter ──────────────────────────────────────────────────────────────
|
|
15
|
-
const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', '
|
|
14
|
+
const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', 'inclusion', 'tags', 'guid'];
|
|
16
15
|
/**
|
|
17
16
|
* Build a primary `.md` file body: YAML frontmatter (built from the object's
|
|
18
17
|
* structured fields) followed by the markdown content.
|
|
@@ -23,12 +22,10 @@ export function buildPrimaryContent(obj) {
|
|
|
23
22
|
if (obj.description !== undefined)
|
|
24
23
|
meta.description = obj.description;
|
|
25
24
|
meta.knowledge_type = obj.knowledge_type;
|
|
26
|
-
if (obj.scope !== undefined)
|
|
27
|
-
meta.scope = obj.scope;
|
|
28
25
|
if (obj.mode !== undefined)
|
|
29
26
|
meta.inclusion = modeToInclusion(obj.mode);
|
|
30
|
-
if (obj.
|
|
31
|
-
meta.
|
|
27
|
+
if (obj.tag !== undefined && obj.tag.length > 0)
|
|
28
|
+
meta.tags = obj.tag;
|
|
32
29
|
if (obj.guid !== undefined)
|
|
33
30
|
meta.guid = obj.guid;
|
|
34
31
|
// Emit keys in a stable, human-friendly order.
|
|
@@ -107,6 +104,10 @@ const MODE_TO_INCLUSION = {
|
|
|
107
104
|
function modeToInclusion(mode) {
|
|
108
105
|
return MODE_TO_INCLUSION[mode] ?? mode;
|
|
109
106
|
}
|
|
107
|
+
/** Parse the on-disk `tags:` frontmatter value into a string array, or `undefined` if absent/malformed. */
|
|
108
|
+
function parseTags(tags) {
|
|
109
|
+
return Array.isArray(tags) && tags.every((t) => typeof t === 'string') ? tags : undefined;
|
|
110
|
+
}
|
|
110
111
|
// ── Path mapping (CLI-owned layout) ────────────────────────────────────────────
|
|
111
112
|
/**
|
|
112
113
|
* Allocate a unique snake_case folder/file segment per call, suffixing
|
|
@@ -246,6 +247,9 @@ export function collectKnowledgeObjects(inputDir, include, exclude) {
|
|
|
246
247
|
};
|
|
247
248
|
if (typeof meta.guid === 'string')
|
|
248
249
|
obj.guid = meta.guid;
|
|
250
|
+
const tags = parseTags(meta.tags);
|
|
251
|
+
if (tags)
|
|
252
|
+
obj.tag = tags;
|
|
249
253
|
if (knowledgeType === 'skill') {
|
|
250
254
|
const skillFolder = posixPath.slice('skills/'.length, posixPath.indexOf('/SKILL.md'));
|
|
251
255
|
const refPrefix = `skills/${skillFolder}/references/`;
|
|
@@ -345,6 +349,7 @@ function canonical(obj) {
|
|
|
345
349
|
files,
|
|
346
350
|
mode: obj.mode ?? '',
|
|
347
351
|
scope: obj.scope ?? '',
|
|
352
|
+
tag: [...(obj.tag ?? [])].sort(),
|
|
348
353
|
});
|
|
349
354
|
}
|
|
350
355
|
/**
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poll a tenant's microservice deployment status until every auto-deployed
|
|
3
|
+
* microservice settles (ok/error) or a timeout elapses. Backs `ephemeral push
|
|
4
|
+
* --wait`: after a multidoc push auto-deploys microservices (tenant_deploy=
|
|
5
|
+
* "auto"), the CLI waits here for them to become Ready and reports the outcome.
|
|
6
|
+
*
|
|
7
|
+
* The backend route (GET .../tenant/{name}/microservice, statement
|
|
8
|
+
* mvp:microservice_status_all) returns one entry per microservice with a live
|
|
9
|
+
* k8s readiness read. `manual`/`disabled` microservices are reported but not
|
|
10
|
+
* waited on (they were never deployed by the push).
|
|
11
|
+
*/
|
|
12
|
+
/** One microservice's status as returned by the tenant status route. */
|
|
13
|
+
export interface MicroserviceStatusEntry {
|
|
14
|
+
desired?: number;
|
|
15
|
+
detail?: string;
|
|
16
|
+
microservice_id: number;
|
|
17
|
+
name: string;
|
|
18
|
+
ready?: number;
|
|
19
|
+
status: string;
|
|
20
|
+
tenant_deploy?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface WaitOptions {
|
|
23
|
+
/** Bearer token for the authed status GET. */
|
|
24
|
+
accessToken: string;
|
|
25
|
+
/** Called once per poll with the latest entries, for progress rendering. */
|
|
26
|
+
onPoll?: (entries: MicroserviceStatusEntry[], elapsedMs: number) => void;
|
|
27
|
+
/** Poll cadence in ms (default 2000). */
|
|
28
|
+
pollIntervalMs?: number;
|
|
29
|
+
/** Absolute URL of the tenant microservice status route. */
|
|
30
|
+
statusUrl: string;
|
|
31
|
+
/** Overall wait budget in ms (default 300000 = 300s). */
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
/** Verbose request logging passthrough. */
|
|
34
|
+
verbose?: boolean;
|
|
35
|
+
/** Authed fetch (command.verboseFetch bound), so TLS/dispatcher settings apply. */
|
|
36
|
+
verboseFetch: (url: string, options: RequestInit, verbose: boolean, authToken?: string) => Promise<Response>;
|
|
37
|
+
}
|
|
38
|
+
export interface WaitResult {
|
|
39
|
+
/** The final status entries observed. */
|
|
40
|
+
entries: MicroserviceStatusEntry[];
|
|
41
|
+
/** True if any awaited (auto) microservice ended in "error". */
|
|
42
|
+
hadError: boolean;
|
|
43
|
+
/** True if the wait budget elapsed before all awaited microservices settled. */
|
|
44
|
+
timedOut: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** An awaited microservice is one that was auto-deployed and is not skipped/disabled. */
|
|
47
|
+
export declare function isAwaited(entry: MicroserviceStatusEntry): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Poll until all awaited microservices settle or the timeout elapses. Transient
|
|
50
|
+
* poll failures (network blips, a brief 5xx while a pod restarts) are tolerated —
|
|
51
|
+
* they don't abort the wait; only the overall timeout does.
|
|
52
|
+
*/
|
|
53
|
+
export declare function waitForMicroservices(opts: WaitOptions): Promise<WaitResult>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poll a tenant's microservice deployment status until every auto-deployed
|
|
3
|
+
* microservice settles (ok/error) or a timeout elapses. Backs `ephemeral push
|
|
4
|
+
* --wait`: after a multidoc push auto-deploys microservices (tenant_deploy=
|
|
5
|
+
* "auto"), the CLI waits here for them to become Ready and reports the outcome.
|
|
6
|
+
*
|
|
7
|
+
* The backend route (GET .../tenant/{name}/microservice, statement
|
|
8
|
+
* mvp:microservice_status_all) returns one entry per microservice with a live
|
|
9
|
+
* k8s readiness read. `manual`/`disabled` microservices are reported but not
|
|
10
|
+
* waited on (they were never deployed by the push).
|
|
11
|
+
*/
|
|
12
|
+
/** Statuses that mean an awaited microservice has settled (no longer in flight). */
|
|
13
|
+
const SETTLED = new Set(['error', 'ok']);
|
|
14
|
+
/** Statuses that mean we don't wait on this microservice at all. */
|
|
15
|
+
const NOT_AWAITED = new Set(['disabled', 'skipped']);
|
|
16
|
+
const sleep = (ms) => new Promise((r) => {
|
|
17
|
+
setTimeout(r, ms);
|
|
18
|
+
});
|
|
19
|
+
/** An awaited microservice is one that was auto-deployed and is not skipped/disabled. */
|
|
20
|
+
export function isAwaited(entry) {
|
|
21
|
+
return !NOT_AWAITED.has(entry.status) && (entry.tenant_deploy ?? 'auto') !== 'manual';
|
|
22
|
+
}
|
|
23
|
+
/** True once every awaited microservice has settled (ok/error). */
|
|
24
|
+
function allSettled(entries) {
|
|
25
|
+
const awaited = entries.filter((e) => isAwaited(e));
|
|
26
|
+
// No auto microservices at all → nothing to wait for.
|
|
27
|
+
if (awaited.length === 0)
|
|
28
|
+
return true;
|
|
29
|
+
return awaited.every((e) => SETTLED.has(e.status));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fetch the status route once. Throws on a non-2xx so the caller can decide
|
|
33
|
+
* whether a transient failure is fatal (it isn't — we keep polling).
|
|
34
|
+
*/
|
|
35
|
+
async function fetchStatus(opts) {
|
|
36
|
+
const res = await opts.verboseFetch(opts.statusUrl, { headers: { Authorization: `Bearer ${opts.accessToken}` }, method: 'GET' }, opts.verbose ?? false, opts.accessToken);
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
throw new Error(`status ${res.status} ${res.statusText}`);
|
|
39
|
+
}
|
|
40
|
+
const body = (await res.json());
|
|
41
|
+
if (!Array.isArray(body)) {
|
|
42
|
+
throw new TypeError('unexpected status response shape');
|
|
43
|
+
}
|
|
44
|
+
return body;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Poll until all awaited microservices settle or the timeout elapses. Transient
|
|
48
|
+
* poll failures (network blips, a brief 5xx while a pod restarts) are tolerated —
|
|
49
|
+
* they don't abort the wait; only the overall timeout does.
|
|
50
|
+
*/
|
|
51
|
+
export async function waitForMicroservices(opts) {
|
|
52
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 2000;
|
|
53
|
+
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
54
|
+
const start = Date.now();
|
|
55
|
+
let entries = [];
|
|
56
|
+
// Poll loop. We always do at least one fetch so `--wait` reports even when the
|
|
57
|
+
// deployment already settled between the push returning and the first poll.
|
|
58
|
+
for (;;) {
|
|
59
|
+
const elapsed = Date.now() - start;
|
|
60
|
+
try {
|
|
61
|
+
// eslint-disable-next-line no-await-in-loop -- polling is sequential by design
|
|
62
|
+
entries = await fetchStatus(opts);
|
|
63
|
+
opts.onPoll?.(entries, elapsed);
|
|
64
|
+
if (allSettled(entries)) {
|
|
65
|
+
return { entries, hadError: entries.some((e) => isAwaited(e) && e.status === 'error'), timedOut: false };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// A transient status read failure is non-fatal — keep polling until the
|
|
70
|
+
// overall timeout. There is deliberately no consecutive-failure cap: we
|
|
71
|
+
// don't surface each blip, and the timeout is the only failure signal.
|
|
72
|
+
}
|
|
73
|
+
if (Date.now() - start + pollIntervalMs > timeoutMs) {
|
|
74
|
+
// Out of budget. Report whatever we last saw as timed out.
|
|
75
|
+
return {
|
|
76
|
+
entries,
|
|
77
|
+
hadError: entries.some((e) => isAwaited(e) && e.status === 'error'),
|
|
78
|
+
timedOut: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// eslint-disable-next-line no-await-in-loop -- pacing the poll loop is the point
|
|
82
|
+
await sleep(pollIntervalMs);
|
|
83
|
+
}
|
|
84
|
+
}
|