@tomflow/proflow-dev-tunnel 0.1.14 → 0.1.15
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/CHANGELOG.md +6 -0
- package/DOCS.md +7 -6
- package/README.md +3 -2
- package/SETUP.md +23 -26
- package/dist/deployment/adapter.d.ts +185 -85
- package/dist/deployment/adapter.js +112 -159
- package/dist/deployment/descriptor.d.ts +2 -2
- package/dist/deployment/descriptor.js +2 -2
- package/dist/deployment/requirements.d.ts +1 -1
- package/dist/src/cli.js +25 -127
- package/dist/src/resource-adapter.d.ts +33 -0
- package/dist/src/resource-adapter.js +271 -5
- package/package.json +4 -3
- package/proflow.module.json +2 -2
|
@@ -2,10 +2,34 @@ import { execFile, spawn } from "node:child_process";
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { connect } from "node:tls";
|
|
5
|
-
|
|
6
|
-
const
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
const LOGIN_ARGS = ["user", "show", "--json"];
|
|
7
|
+
const LOGIN_TIMEOUT_MS = 30_000;
|
|
8
|
+
const REMOTE_COMMAND_TIMEOUT_MS = 30_000;
|
|
7
9
|
const START_CONFIRM_MS = 500;
|
|
8
10
|
function defaultCommandRunner(command, args, options) {
|
|
11
|
+
if (options?.interactive) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
14
|
+
let timedOut = false;
|
|
15
|
+
const timer = setTimeout(() => {
|
|
16
|
+
timedOut = true;
|
|
17
|
+
child.kill("SIGTERM");
|
|
18
|
+
}, options.timeoutMs ?? 600_000);
|
|
19
|
+
child.once("error", (error) => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
resolve({ exitCode: null, stdout: "", stderr: error.message });
|
|
22
|
+
});
|
|
23
|
+
child.once("exit", (code) => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolve({
|
|
26
|
+
exitCode: timedOut ? null : code,
|
|
27
|
+
stdout: "",
|
|
28
|
+
stderr: timedOut ? "command timed out" : "",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
9
33
|
return new Promise((resolve) => {
|
|
10
34
|
execFile(command, args, { timeout: options?.timeoutMs ?? 10_000 }, (error, stdout, stderr) => {
|
|
11
35
|
if (error) {
|
|
@@ -33,6 +57,244 @@ function defaultCommandRunner(command, args, options) {
|
|
|
33
57
|
});
|
|
34
58
|
});
|
|
35
59
|
}
|
|
60
|
+
const loginStatusSchema = z.object({ status: z.string().min(1) }).passthrough();
|
|
61
|
+
const tunnelPortSchema = z
|
|
62
|
+
.object({
|
|
63
|
+
portNumber: z.number().int().min(1).max(65_535),
|
|
64
|
+
protocol: z.string().min(1).optional(),
|
|
65
|
+
portForwardingUris: z.array(z.string()).optional(),
|
|
66
|
+
portUri: z.string().optional(),
|
|
67
|
+
clientConnections: z.number().int().min(0).optional(),
|
|
68
|
+
})
|
|
69
|
+
.passthrough();
|
|
70
|
+
const tunnelPayloadSchema = z
|
|
71
|
+
.object({
|
|
72
|
+
tunnelId: z.string().min(1),
|
|
73
|
+
endpoints: z.array(z.unknown()).optional(),
|
|
74
|
+
hostConnections: z.number().int().min(0).optional(),
|
|
75
|
+
ports: z.array(tunnelPortSchema).optional(),
|
|
76
|
+
})
|
|
77
|
+
.passthrough();
|
|
78
|
+
function parseTunnel(input) {
|
|
79
|
+
const direct = tunnelPayloadSchema.safeParse(input);
|
|
80
|
+
if (direct.success)
|
|
81
|
+
return direct.data;
|
|
82
|
+
return z.object({ tunnel: tunnelPayloadSchema }).passthrough().parse(input)
|
|
83
|
+
.tunnel;
|
|
84
|
+
}
|
|
85
|
+
function parseJson(text, label) {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Error(`${label} returned malformed JSON`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function parsePorts(input) {
|
|
94
|
+
if (Array.isArray(input))
|
|
95
|
+
return z.array(tunnelPortSchema).parse(input);
|
|
96
|
+
if (typeof input === "object" && input !== null) {
|
|
97
|
+
const ports = Reflect.get(input, "ports");
|
|
98
|
+
if (Array.isArray(ports))
|
|
99
|
+
return z.array(tunnelPortSchema).parse(ports);
|
|
100
|
+
const tunnel = Reflect.get(input, "tunnel");
|
|
101
|
+
if (typeof tunnel === "object" && tunnel !== null) {
|
|
102
|
+
const tunnelPorts = Reflect.get(tunnel, "ports");
|
|
103
|
+
if (Array.isArray(tunnelPorts))
|
|
104
|
+
return z.array(tunnelPortSchema).parse(tunnelPorts);
|
|
105
|
+
}
|
|
106
|
+
const port = Reflect.get(input, "port");
|
|
107
|
+
if (port !== undefined)
|
|
108
|
+
return [tunnelPortSchema.parse(port)];
|
|
109
|
+
const one = tunnelPortSchema.safeParse(input);
|
|
110
|
+
if (one.success)
|
|
111
|
+
return [one.data];
|
|
112
|
+
}
|
|
113
|
+
throw new Error("devtunnel port JSON does not contain a valid port list");
|
|
114
|
+
}
|
|
115
|
+
export function parseDevTunnelLoginStatus(input) {
|
|
116
|
+
const parsed = loginStatusSchema.safeParse(input);
|
|
117
|
+
if (!parsed.success)
|
|
118
|
+
return "UNKNOWN";
|
|
119
|
+
const status = parsed.data.status.trim().toLowerCase();
|
|
120
|
+
if (/(expired|not logged|login required|sign[ -]?in required|not authenticated)/.test(status))
|
|
121
|
+
return "NOT_LOGGED_IN";
|
|
122
|
+
if (/(logged in|authenticated)/.test(status))
|
|
123
|
+
return "LOGGED_IN";
|
|
124
|
+
return "UNKNOWN";
|
|
125
|
+
}
|
|
126
|
+
export function discoverPublicBaseUrl(input, port) {
|
|
127
|
+
const matching = parsePorts(input).filter((item) => item.portNumber === port);
|
|
128
|
+
if (matching.length !== 1)
|
|
129
|
+
throw new Error("devtunnel JSON does not identify exactly one current Gateway port");
|
|
130
|
+
const current = matching[0];
|
|
131
|
+
const uris = [
|
|
132
|
+
...(current?.portUri ? [current.portUri] : []),
|
|
133
|
+
...(current?.portForwardingUris ?? []),
|
|
134
|
+
];
|
|
135
|
+
const httpsUris = uris.flatMap((raw) => {
|
|
136
|
+
try {
|
|
137
|
+
const url = new URL(raw);
|
|
138
|
+
return url.protocol === "https:" &&
|
|
139
|
+
(url.port === "" || url.port === "443")
|
|
140
|
+
? [url.href]
|
|
141
|
+
: [];
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
if (httpsUris.length === 0)
|
|
148
|
+
throw new Error("current Gateway port has no valid HTTPS forwarding URI");
|
|
149
|
+
return httpsUris[0];
|
|
150
|
+
}
|
|
151
|
+
function commandText(result) {
|
|
152
|
+
return `${result.stdout}\n${result.stderr}`;
|
|
153
|
+
}
|
|
154
|
+
function assertCommandSucceeded(result, label) {
|
|
155
|
+
if (result.exitCode !== 0)
|
|
156
|
+
throw new Error(`${label} failed${result.exitCode === null ? " or timed out" : ""}`);
|
|
157
|
+
}
|
|
158
|
+
export function createDevTunnelAutomation(input) {
|
|
159
|
+
const command = input?.command ?? "devtunnel";
|
|
160
|
+
const run = input?.runCommand ?? defaultCommandRunner;
|
|
161
|
+
const loginStatus = async () => {
|
|
162
|
+
let result;
|
|
163
|
+
try {
|
|
164
|
+
result = await run(command, ["user", "show", "--json"], {
|
|
165
|
+
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return "UNKNOWN";
|
|
170
|
+
}
|
|
171
|
+
if (result.exitCode === null)
|
|
172
|
+
return "UNKNOWN";
|
|
173
|
+
try {
|
|
174
|
+
return parseDevTunnelLoginStatus(parseJson(result.stdout, "devtunnel user show --json"));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return "UNKNOWN";
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
async ensureLogin() {
|
|
182
|
+
const before = await loginStatus();
|
|
183
|
+
if (before === "LOGGED_IN")
|
|
184
|
+
return before;
|
|
185
|
+
if (before === "UNKNOWN")
|
|
186
|
+
throw new Error("Dev Tunnel login status is UNKNOWN");
|
|
187
|
+
const login = await run(command, ["user", "login", "--github", "--use-browser-auth"], { timeoutMs: 600_000, interactive: true });
|
|
188
|
+
assertCommandSucceeded(login, "GitHub browser authentication");
|
|
189
|
+
const after = await loginStatus();
|
|
190
|
+
if (after !== "LOGGED_IN")
|
|
191
|
+
throw new Error("Dev Tunnel login was not confirmed after authentication");
|
|
192
|
+
return after;
|
|
193
|
+
},
|
|
194
|
+
async inspectTunnel(tunnelId) {
|
|
195
|
+
const result = await run(command, ["show", tunnelId, "--json"], {
|
|
196
|
+
timeoutMs: REMOTE_COMMAND_TIMEOUT_MS,
|
|
197
|
+
});
|
|
198
|
+
if (result.exitCode !== 0) {
|
|
199
|
+
return /not found|does not exist|could not be found/i.test(commandText(result))
|
|
200
|
+
? { state: "MISSING", hostState: "UNKNOWN" }
|
|
201
|
+
: { state: "UNKNOWN", hostState: "UNKNOWN" };
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const tunnel = parseTunnel(parseJson(result.stdout, "devtunnel show --json"));
|
|
205
|
+
if (tunnel.tunnelId !== tunnelId)
|
|
206
|
+
return { state: "UNKNOWN", hostState: "UNKNOWN" };
|
|
207
|
+
const hostState = tunnel.hostConnections !== undefined
|
|
208
|
+
? tunnel.hostConnections > 0
|
|
209
|
+
? "RUNNING"
|
|
210
|
+
: "STOPPED"
|
|
211
|
+
: tunnel.endpoints === undefined
|
|
212
|
+
? "UNKNOWN"
|
|
213
|
+
: tunnel.endpoints.length === 0
|
|
214
|
+
? "STOPPED"
|
|
215
|
+
: "RUNNING";
|
|
216
|
+
return { state: "EXISTS", hostState };
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return { state: "UNKNOWN", hostState: "UNKNOWN" };
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
async createTunnel() {
|
|
223
|
+
const result = await run(command, ["create", "--allow-anonymous", "--json"], { timeoutMs: REMOTE_COMMAND_TIMEOUT_MS });
|
|
224
|
+
assertCommandSucceeded(result, "devtunnel create --json");
|
|
225
|
+
const tunnel = parseTunnel(parseJson(result.stdout, "devtunnel create --json"));
|
|
226
|
+
return tunnel.tunnelId;
|
|
227
|
+
},
|
|
228
|
+
async ensurePort(tunnelId, port) {
|
|
229
|
+
const listed = await run(command, ["port", "list", tunnelId, "--json"], {
|
|
230
|
+
timeoutMs: REMOTE_COMMAND_TIMEOUT_MS,
|
|
231
|
+
});
|
|
232
|
+
assertCommandSucceeded(listed, "devtunnel port list --json");
|
|
233
|
+
const existing = parsePorts(parseJson(listed.stdout, "devtunnel port list --json")).find((item) => item.portNumber === port);
|
|
234
|
+
if (existing?.protocol?.toLowerCase() === "http")
|
|
235
|
+
return "REUSED";
|
|
236
|
+
if (existing) {
|
|
237
|
+
const removed = await run(command, ["port", "delete", tunnelId, "--port-number", String(port), "--json"], { timeoutMs: REMOTE_COMMAND_TIMEOUT_MS });
|
|
238
|
+
assertCommandSucceeded(removed, "devtunnel port delete --json");
|
|
239
|
+
}
|
|
240
|
+
const mutation = await run(command, [
|
|
241
|
+
"port",
|
|
242
|
+
"create",
|
|
243
|
+
tunnelId,
|
|
244
|
+
"--port-number",
|
|
245
|
+
String(port),
|
|
246
|
+
"--protocol",
|
|
247
|
+
"http",
|
|
248
|
+
"--json",
|
|
249
|
+
], { timeoutMs: REMOTE_COMMAND_TIMEOUT_MS });
|
|
250
|
+
assertCommandSucceeded(mutation, "devtunnel port create --json");
|
|
251
|
+
const confirmed = parsePorts(parseJson(mutation.stdout, "devtunnel port create --json"))[0];
|
|
252
|
+
if (confirmed === undefined ||
|
|
253
|
+
confirmed.portNumber !== port ||
|
|
254
|
+
confirmed.protocol?.toLowerCase() !== "http")
|
|
255
|
+
throw new Error("Dev Tunnel port mutation did not confirm the Gateway port");
|
|
256
|
+
return existing ? "UPDATED" : "CREATED";
|
|
257
|
+
},
|
|
258
|
+
async discoverPublicBaseUrl(tunnelId, port) {
|
|
259
|
+
let lastError;
|
|
260
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
261
|
+
const shown = await run(command, ["show", tunnelId, "--json"], {
|
|
262
|
+
timeoutMs: REMOTE_COMMAND_TIMEOUT_MS,
|
|
263
|
+
});
|
|
264
|
+
assertCommandSucceeded(shown, "devtunnel show --json");
|
|
265
|
+
try {
|
|
266
|
+
return discoverPublicBaseUrl(parseJson(shown.stdout, "devtunnel show --json"), port);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
lastError = error;
|
|
270
|
+
if (attempt < 2)
|
|
271
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
throw lastError instanceof Error
|
|
275
|
+
? lastError
|
|
276
|
+
: new Error("publicBaseUrl discovery failed");
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
export async function verifyProvisionedPublicBaseUrl(publicBaseUrl, timeoutMs = 10_000) {
|
|
281
|
+
const url = new URL(publicBaseUrl);
|
|
282
|
+
const port = url.port === "" ? 443 : Number(url.port);
|
|
283
|
+
if (url.protocol !== "https:" || port !== 443)
|
|
284
|
+
throw new Error("publicBaseUrl must use HTTPS on port 443");
|
|
285
|
+
const protocol = await probeTlsProtocol(url.hostname, port, timeoutMs);
|
|
286
|
+
if (protocol === undefined || !tlsProtocolAtLeast(protocol, "TLSv1.2"))
|
|
287
|
+
throw new Error("publicBaseUrl did not negotiate TLS 1.2 or newer");
|
|
288
|
+
try {
|
|
289
|
+
await fetch(url, {
|
|
290
|
+
method: "GET",
|
|
291
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
throw new Error("publicBaseUrl is not reachable over HTTPS");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
36
298
|
async function readProcessRecord(file) {
|
|
37
299
|
if (!file)
|
|
38
300
|
return undefined;
|
|
@@ -120,10 +382,14 @@ export function createDevTunnelRuntime(input) {
|
|
|
120
382
|
catch {
|
|
121
383
|
return "UNKNOWN";
|
|
122
384
|
}
|
|
123
|
-
if (result.exitCode ===
|
|
124
|
-
return
|
|
385
|
+
if (result.exitCode === null)
|
|
386
|
+
return "UNKNOWN";
|
|
387
|
+
try {
|
|
388
|
+
return parseDevTunnelLoginStatus(parseJson(result.stdout, "devtunnel user show --json"));
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return "UNKNOWN";
|
|
125
392
|
}
|
|
126
|
-
return result.exitCode === null ? "UNKNOWN" : "NOT_LOGGED_IN";
|
|
127
393
|
};
|
|
128
394
|
const observe = async (state) => {
|
|
129
395
|
const login = await observeLogin();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomflow/proflow-dev-tunnel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
"SETUP.md"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"
|
|
25
|
+
"zod": "4.1.12",
|
|
26
|
+
"@tomflow/proflow-module-contract": "^0.1.13"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
|
-
"@tomflow/proflow-deployment-conformance": "^0.1.
|
|
29
|
+
"@tomflow/proflow-deployment-conformance": "^0.1.11"
|
|
29
30
|
},
|
|
30
31
|
"description": "Governs the Microsoft Dev Tunnel public HTTPS ingress resource and its managed local host process.",
|
|
31
32
|
"keywords": [
|
package/proflow.module.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"contractVersion": "1.0.0",
|
|
4
4
|
"moduleRef": "dev-tunnel",
|
|
5
5
|
"packageName": "@tomflow/proflow-dev-tunnel",
|
|
6
|
-
"moduleVersion": "0.1.
|
|
6
|
+
"moduleVersion": "0.1.15",
|
|
7
7
|
"kind": "external-resource",
|
|
8
8
|
"templateVersion": "1.0.0",
|
|
9
9
|
"platformCompatibility": ">=1.0.0 <2.0.0",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"kind": "human",
|
|
28
|
-
"action": "Complete
|
|
28
|
+
"action": "Complete GitHub browser authorization when login is required"
|
|
29
29
|
}
|
|
30
30
|
],
|
|
31
31
|
"configSlots": [],
|