wawesome 0.22.0 → 0.24.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/dist/assets-Cgw3_rAq.mjs +336 -0
- package/dist/index.mjs +124 -398
- package/dist/local-modules-DvEInmfO.mjs +225 -0
- package/dist/local-modules.d.mts +16 -0
- package/dist/local-modules.mjs +2 -0
- package/dist/vite.d.mts +8 -2
- package/dist/vite.mjs +53 -3
- package/dist/vitest-setup.mjs +7 -0
- package/package.json +6 -2
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { C as asGatewayError, E as rejectionOf, S as GatewayError, T as reasonOf, a as manifestOf, b as reportSessionCommandRefused, c as getDashboardUrl, d as readCredentials, f as readFunctionConfig, g as CREDENTIAL_ENV_VAR, h as writeCredentials, l as getFunctionConfigPath, m as resolveGatewayUrlWithoutSession, n as deployDigest, o as LOGIN_CALLBACK_PORT, p as resolveDashboardUrlForLogin, r as isDocument, s as deleteCredentials, t as collectAssets, u as getGatewayUrl, v as credentialFromEnvironment, w as errorText, x as unauthorizedMessage, y as credentialPrefix } from "./assets-Cgw3_rAq.mjs";
|
|
1
2
|
import { i as polyfilledGlobals, n as declaredModules, o as unsupportedGlobals, r as methodRemedy, t as declaredMethods } from "./guest-surface-BwrNP2Cz.mjs";
|
|
2
3
|
import cac from "cac";
|
|
3
4
|
import fs from "node:fs";
|
|
@@ -10,7 +11,7 @@ import crypto from "node:crypto";
|
|
|
10
11
|
import http from "node:http";
|
|
11
12
|
import { Readable } from "node:stream";
|
|
12
13
|
import readline from "node:readline";
|
|
13
|
-
import { confirm, select } from "@inquirer/prompts";
|
|
14
|
+
import { confirm, input, select } from "@inquirer/prompts";
|
|
14
15
|
import zlib from "node:zlib";
|
|
15
16
|
//#region src/brand.ts
|
|
16
17
|
const PLAIN = "[w] wawesome";
|
|
@@ -26,249 +27,6 @@ function lockup(stream = process.stdout, env = process.env) {
|
|
|
26
27
|
}
|
|
27
28
|
const LOCKUP_HTML = "<div style=\"margin-bottom: 24px; font: 700 20px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: -0.02em; color: #e8eaf0\"><span style=\"font-size: 22px; letter-spacing: -0.06em\"><span style=\"color: #6b7cff\">[</span>w<span style=\"color: #6b7cff\">]</span></span> wawesome</div>";
|
|
28
29
|
//#endregion
|
|
29
|
-
//#region src/errors.ts
|
|
30
|
-
/**
|
|
31
|
-
* A rejection carrying the gateway's own prose and, where given, its reason.
|
|
32
|
-
*
|
|
33
|
-
* The call's trace id rides in the message itself, on a line of its own, so
|
|
34
|
-
* every place that prints a refusal prints the one thing support can search by.
|
|
35
|
-
*/
|
|
36
|
-
var GatewayError = class extends Error {
|
|
37
|
-
status;
|
|
38
|
-
reason;
|
|
39
|
-
/** The raw response, for `--verbose` to print when the prose is not enough. */
|
|
40
|
-
body;
|
|
41
|
-
traceId;
|
|
42
|
-
constructor(message, status, reason, body = "", traceId) {
|
|
43
|
-
super(traceId ? `${message}\n${traceLine(traceId)}` : message);
|
|
44
|
-
this.name = "GatewayError";
|
|
45
|
-
this.status = status;
|
|
46
|
-
this.reason = reason;
|
|
47
|
-
this.body = body;
|
|
48
|
-
this.traceId = traceId;
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
function traceLine(traceId) {
|
|
52
|
-
return `Trace ID: ${traceId}`;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Read a failed response into a `GatewayError`.
|
|
56
|
-
*
|
|
57
|
-
* `fallback` is used only when the response carries no prose of its own — a
|
|
58
|
-
* proxy's HTML error page, or an empty body. A 401 is deliberately *not* special
|
|
59
|
-
* cased here: what to say about an expired session depends on what the caller
|
|
60
|
-
* was doing, so callers handle it before reaching this.
|
|
61
|
-
*/
|
|
62
|
-
async function asGatewayError(res, fallback) {
|
|
63
|
-
const traceId = res.headers?.get("x-wawesome-trace-id") ?? void 0;
|
|
64
|
-
return rejectionOf(await res.text().catch(() => ""), res.status, fallback, traceId);
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* The same reading, for a caller that has already taken the body off the wire.
|
|
68
|
-
* `traceId` is the header's, for a body that never made it here as JSON.
|
|
69
|
-
*/
|
|
70
|
-
function rejectionOf(body, status, fallback, traceId) {
|
|
71
|
-
try {
|
|
72
|
-
const parsed = JSON.parse(body);
|
|
73
|
-
return new GatewayError(parsed.error || fallback, status, parsed.reason, body, parsed.trace_id || traceId);
|
|
74
|
-
} catch {
|
|
75
|
-
return new GatewayError(fallback, status, void 0, body, traceId);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
/** Why the gateway refused, where what was thrown might not be a refusal at all. */
|
|
79
|
-
function reasonOf(err) {
|
|
80
|
-
return err instanceof GatewayError ? err.reason : void 0;
|
|
81
|
-
}
|
|
82
|
-
/** What went wrong, as text, whatever was thrown. */
|
|
83
|
-
function errorText(err) {
|
|
84
|
-
return err instanceof Error ? err.message : String(err);
|
|
85
|
-
}
|
|
86
|
-
//#endregion
|
|
87
|
-
//#region src/credential.ts
|
|
88
|
-
const CREDENTIAL_ENV_VAR = "WAWESOME_DEPLOY_CREDENTIAL";
|
|
89
|
-
function credentialFromEnvironment() {
|
|
90
|
-
const secret = process.env[CREDENTIAL_ENV_VAR]?.trim();
|
|
91
|
-
return secret ? secret : null;
|
|
92
|
-
}
|
|
93
|
-
/** Enough to match a credential against the one in a CI settings page, and no more. */
|
|
94
|
-
function credentialPrefix(secret) {
|
|
95
|
-
return secret.slice(0, 11);
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* A credential has no session behind it, so the platform's own reason — unknown,
|
|
99
|
-
* revoked, expired, or a scope it does not carry — is the whole answer, and
|
|
100
|
-
* "sign in again" names a door a CI runner cannot open.
|
|
101
|
-
*/
|
|
102
|
-
function unauthorizedMessage(status, body) {
|
|
103
|
-
const secret = credentialFromEnvironment();
|
|
104
|
-
if (!secret) {
|
|
105
|
-
const refusal = rejectionOf(body, status, `The platform refused this request (HTTP ${status}).`);
|
|
106
|
-
if (status !== 401) return refusal.message;
|
|
107
|
-
const expired = "Authentication expired. Run 'wawesome login' again.";
|
|
108
|
-
return refusal.traceId ? `${expired}\n${traceLine(refusal.traceId)}` : expired;
|
|
109
|
-
}
|
|
110
|
-
return rejectionOf(body, status, `The deploy credential ${credentialPrefix(secret)} was refused (HTTP ${status}).`).message;
|
|
111
|
-
}
|
|
112
|
-
function reportSessionCommandRefused(secret, consequence) {
|
|
113
|
-
console.error(`[wawesome] Error: ${CREDENTIAL_ENV_VAR} is set, so this CLI authenticates as deploy credential ${credentialPrefix(secret)}.`);
|
|
114
|
-
console.error(`[wawesome] ${consequence} Unset ${CREDENTIAL_ENV_VAR} first.`);
|
|
115
|
-
}
|
|
116
|
-
//#endregion
|
|
117
|
-
//#region src/config.ts
|
|
118
|
-
/** Path to the user-level settings file (~/.wawesome/settings.json) */
|
|
119
|
-
function getSettingsPath() {
|
|
120
|
-
return path.join(os.homedir(), ".wawesome", "settings.json");
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Read stored user settings from ~/.wawesome/settings.json.
|
|
124
|
-
*/
|
|
125
|
-
function readSettings() {
|
|
126
|
-
const settingsPath = getSettingsPath();
|
|
127
|
-
if (!fs.existsSync(settingsPath)) return {};
|
|
128
|
-
try {
|
|
129
|
-
const raw = fs.readFileSync(settingsPath, "utf-8");
|
|
130
|
-
return JSON.parse(raw);
|
|
131
|
-
} catch {
|
|
132
|
-
return {};
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
const GATEWAY_ENV_NAMES = ["GATEWAY_URL", "WAWESOME_GATEWAY_URL"];
|
|
136
|
-
const DASHBOARD_ENV_NAMES = ["DASHBOARD_URL", "WAWESOME_DASHBOARD_URL"];
|
|
137
|
-
const GATEWAY_URL_FALLBACK = "https://api.wawesome.io";
|
|
138
|
-
const DASHBOARD_URL_FALLBACK = "https://dashboard.wawesome.io";
|
|
139
|
-
function resolveAddress(overrideUrl, envNames, settingsKey, stored, fallback) {
|
|
140
|
-
if (overrideUrl) return overrideUrl;
|
|
141
|
-
for (const name of envNames) {
|
|
142
|
-
const fromEnv = process.env[name];
|
|
143
|
-
if (fromEnv) return fromEnv;
|
|
144
|
-
}
|
|
145
|
-
const configured = readSettings()[settingsKey];
|
|
146
|
-
if (configured && typeof configured === "string") return configured;
|
|
147
|
-
return stored || fallback;
|
|
148
|
-
}
|
|
149
|
-
function getGatewayUrl(overrideUrl) {
|
|
150
|
-
return resolveAddress(overrideUrl, GATEWAY_ENV_NAMES, "gateway_url", readStoredCredentials()?.gateway_url, GATEWAY_URL_FALLBACK);
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* A tenant token is only valid at the gateway that issued it, so every other
|
|
154
|
-
* command has to follow the credentials. A login holds no token yet, and
|
|
155
|
-
* reading that rung here made a local gateway outlive the removal of every
|
|
156
|
-
* documented override, since only `logout` clears it. A **Deploy credential**
|
|
157
|
-
* reads the same rungs, and for a sharper reason: following a stale file would
|
|
158
|
-
* present it at a gateway that never minted it.
|
|
159
|
-
*/
|
|
160
|
-
function resolveGatewayUrlWithoutSession(overrideUrl) {
|
|
161
|
-
return resolveAddress(overrideUrl, GATEWAY_ENV_NAMES, "gateway_url", void 0, GATEWAY_URL_FALLBACK);
|
|
162
|
-
}
|
|
163
|
-
getGatewayUrl();
|
|
164
|
-
function getDashboardUrl(overrideUrl) {
|
|
165
|
-
return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", readStoredCredentials()?.dashboard_url, DASHBOARD_URL_FALLBACK);
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* The dashboard for the environment this login is going to.
|
|
169
|
-
*
|
|
170
|
-
* Taken from the address the platform handed back to open, so a login that
|
|
171
|
-
* configures nothing stores the dashboard belonging to the gateway it signed
|
|
172
|
-
* into. An explicit flag, `DASHBOARD_URL` or a settings file still outrank it,
|
|
173
|
-
* on the rung they outrank every other address on.
|
|
174
|
-
*/
|
|
175
|
-
function resolveDashboardUrlForLogin(overrideUrl, signInUrl) {
|
|
176
|
-
return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", originOf(signInUrl), DASHBOARD_URL_FALLBACK);
|
|
177
|
-
}
|
|
178
|
-
/** Undefined rather than throwing: an unreadable address falls to the rung below. */
|
|
179
|
-
function originOf(url) {
|
|
180
|
-
try {
|
|
181
|
-
return new URL(url).origin;
|
|
182
|
-
} catch {
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
const LOGIN_CALLBACK_PORT = 9999;
|
|
187
|
-
/** Path to the user-level credentials file */
|
|
188
|
-
function getCredentialsPath() {
|
|
189
|
-
return path.join(os.homedir(), ".wawesome", "credentials.json");
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* What this process authenticates with: the **Deploy credential** in the
|
|
193
|
-
* environment where one is set, and otherwise the session `login` left on disk.
|
|
194
|
-
*
|
|
195
|
-
* The environment wins outright rather than filling a gap, so a CI runner
|
|
196
|
-
* carrying a stale cached home directory still authenticates as itself.
|
|
197
|
-
*/
|
|
198
|
-
function readCredentials() {
|
|
199
|
-
const secret = credentialFromEnvironment();
|
|
200
|
-
return secret ? environmentCredential(secret) : readStoredCredentials();
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* A credential wears the shape of a session so that every command reaches the
|
|
204
|
-
* platform the same way. What it does not carry is the point: no refresh token,
|
|
205
|
-
* because there is nothing behind it to renew, and no Tenant, because the
|
|
206
|
-
* platform resolves that from the credential.
|
|
207
|
-
*/
|
|
208
|
-
function environmentCredential(secret) {
|
|
209
|
-
return {
|
|
210
|
-
gateway_url: resolveGatewayUrlWithoutSession(void 0),
|
|
211
|
-
tenant_jwt: secret,
|
|
212
|
-
credential_prefix: credentialPrefix(secret)
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
/** The session on disk, whatever the environment says. */
|
|
216
|
-
function readStoredCredentials() {
|
|
217
|
-
const credPath = getCredentialsPath();
|
|
218
|
-
if (!fs.existsSync(credPath)) return null;
|
|
219
|
-
try {
|
|
220
|
-
const raw = fs.readFileSync(credPath, "utf-8");
|
|
221
|
-
return JSON.parse(raw);
|
|
222
|
-
} catch {
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Write credentials to disk, creating the directory if needed.
|
|
228
|
-
*
|
|
229
|
-
* Owner-only, because the file holds a refresh token. The `chmod` is what
|
|
230
|
-
* tightens a file an older CLI left world-readable; a mode passed to
|
|
231
|
-
* `writeFileSync` applies only where it creates the file.
|
|
232
|
-
*/
|
|
233
|
-
function writeCredentials(creds) {
|
|
234
|
-
if (credentialFromEnvironment()) return;
|
|
235
|
-
const credPath = getCredentialsPath();
|
|
236
|
-
const dir = path.dirname(credPath);
|
|
237
|
-
fs.mkdirSync(dir, {
|
|
238
|
-
recursive: true,
|
|
239
|
-
mode: 448
|
|
240
|
-
});
|
|
241
|
-
fs.writeFileSync(credPath, JSON.stringify(creds, null, 2), {
|
|
242
|
-
encoding: "utf-8",
|
|
243
|
-
mode: 384
|
|
244
|
-
});
|
|
245
|
-
fs.chmodSync(credPath, 384);
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Delete stored credentials.
|
|
249
|
-
*/
|
|
250
|
-
function deleteCredentials() {
|
|
251
|
-
const credPath = getCredentialsPath();
|
|
252
|
-
if (fs.existsSync(credPath)) {
|
|
253
|
-
fs.rmSync(credPath, { force: true });
|
|
254
|
-
return true;
|
|
255
|
-
}
|
|
256
|
-
return false;
|
|
257
|
-
}
|
|
258
|
-
function getFunctionConfigPath(projectDir) {
|
|
259
|
-
return path.join(projectDir || process.cwd(), "wawesome-function.json");
|
|
260
|
-
}
|
|
261
|
-
function readFunctionConfig(projectDir) {
|
|
262
|
-
const configPath = getFunctionConfigPath(projectDir);
|
|
263
|
-
if (!fs.existsSync(configPath)) return null;
|
|
264
|
-
try {
|
|
265
|
-
const raw = fs.readFileSync(configPath, "utf-8");
|
|
266
|
-
return JSON.parse(raw);
|
|
267
|
-
} catch {
|
|
268
|
-
return null;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
//#endregion
|
|
272
30
|
//#region src/sourcemap.ts
|
|
273
31
|
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
274
32
|
const NO_POSITION = () => null;
|
|
@@ -929,7 +687,7 @@ async function buildHandler(entryInput, config, options) {
|
|
|
929
687
|
* that has to name this version — `--version`, the dependency a scaffolded
|
|
930
688
|
* project pins — reads it here, so a release bumps one file.
|
|
931
689
|
*/
|
|
932
|
-
const CLI_VERSION = "0.
|
|
690
|
+
const CLI_VERSION = "0.24.0";
|
|
933
691
|
//#endregion
|
|
934
692
|
//#region src/cli-login.ts
|
|
935
693
|
const START_PATH = "/v1/cli/login/start";
|
|
@@ -1309,6 +1067,44 @@ function endSession(said) {
|
|
|
1309
1067
|
process.exit(1);
|
|
1310
1068
|
}
|
|
1311
1069
|
//#endregion
|
|
1070
|
+
//#region src/schedules.ts
|
|
1071
|
+
/**
|
|
1072
|
+
* When a schedule next runs, or why it does not run at all.
|
|
1073
|
+
*
|
|
1074
|
+
* The three ways of being off never render alike, and a schedule that is off
|
|
1075
|
+
* twice over says so: someone who reads only the pause will declare it again,
|
|
1076
|
+
* press resume, and file a bug when neither starts it.
|
|
1077
|
+
*/
|
|
1078
|
+
function whenItRuns(schedule) {
|
|
1079
|
+
const undeclared = schedule.declared === false;
|
|
1080
|
+
if (schedule.state === "suspended") {
|
|
1081
|
+
const why = "The workspace owes payment, its subscription ended, or it has spent past its plan's allowance";
|
|
1082
|
+
return undeclared ? `suspended, and not in this config file. ${why}; declare it again too` : `suspended. ${why}`;
|
|
1083
|
+
}
|
|
1084
|
+
if (schedule.state === "paused") return undeclared ? "paused, and not in this config file. Declare it again, then resume" : "paused. Resume it to run again";
|
|
1085
|
+
if (undeclared) return "not in this config file, so it is disabled. Its history is kept";
|
|
1086
|
+
if (schedule.state !== "active") return schedule.state;
|
|
1087
|
+
return schedule.next_fire_at ? `next run ${asUtc$1(schedule.next_fire_at)}` : "next run unknown";
|
|
1088
|
+
}
|
|
1089
|
+
/** `2026-08-23 03:00 UTC`, which is the zone every expression is read in. */
|
|
1090
|
+
function asUtc$1(iso) {
|
|
1091
|
+
const at = new Date(iso);
|
|
1092
|
+
if (Number.isNaN(at.getTime())) return iso;
|
|
1093
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1094
|
+
return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())} ${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())} UTC`;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* The block a deploy prints for the schedules it just applied. Every schedule
|
|
1098
|
+
* appears, including the ones that are off, so putting recurring compute on the
|
|
1099
|
+
* bill — or taking it off — is never silent.
|
|
1100
|
+
*/
|
|
1101
|
+
function scheduleLines(schedules) {
|
|
1102
|
+
if (schedules.length === 0) return [];
|
|
1103
|
+
const nameWidth = Math.max(...schedules.map((s) => s.name.length));
|
|
1104
|
+
const expressionWidth = Math.max(...schedules.map((s) => s.expression.length));
|
|
1105
|
+
return [" Schedules:", ...[...schedules].sort((a, b) => a.name.localeCompare(b.name)).map((schedule) => ` ${schedule.name.padEnd(nameWidth)} ${schedule.expression.padEnd(expressionWidth)} ${whenItRuns(schedule)}`)];
|
|
1106
|
+
}
|
|
1107
|
+
//#endregion
|
|
1312
1108
|
//#region src/tenant.ts
|
|
1313
1109
|
async function fetchTenantDetails(creds) {
|
|
1314
1110
|
const res = await authorizedFetch(`${creds.gateway_url}/v1/tenant`, { headers: { Authorization: `Bearer ${creds.tenant_jwt}` } });
|
|
@@ -1344,8 +1140,12 @@ async function resolveWorkspace(creds) {
|
|
|
1344
1140
|
* telling a user different things about the same reason — and `retryable` is
|
|
1345
1141
|
* what separates a name worth retyping from one that no name would fix.
|
|
1346
1142
|
*/
|
|
1347
|
-
function renameAdvice(
|
|
1348
|
-
|
|
1143
|
+
function renameAdvice(err) {
|
|
1144
|
+
if (!(err instanceof GatewayError)) return {
|
|
1145
|
+
text: "",
|
|
1146
|
+
retryable: false
|
|
1147
|
+
};
|
|
1148
|
+
switch (err.reason) {
|
|
1349
1149
|
case "taken":
|
|
1350
1150
|
case "reserved": return {
|
|
1351
1151
|
text: "Pick a different name.",
|
|
@@ -1360,7 +1160,7 @@ function renameAdvice(reason) {
|
|
|
1360
1160
|
retryable: true
|
|
1361
1161
|
};
|
|
1362
1162
|
case "locked": return {
|
|
1363
|
-
text:
|
|
1163
|
+
text: err.renamableAt ? nextRenameAllowed(err.renamableAt) : "",
|
|
1364
1164
|
retryable: false
|
|
1365
1165
|
};
|
|
1366
1166
|
default: return {
|
|
@@ -1369,6 +1169,9 @@ function renameAdvice(reason) {
|
|
|
1369
1169
|
};
|
|
1370
1170
|
}
|
|
1371
1171
|
}
|
|
1172
|
+
function nextRenameAllowed(renamableAt) {
|
|
1173
|
+
return `The next rename is allowed from ${asUtc$1(renamableAt)}.`;
|
|
1174
|
+
}
|
|
1372
1175
|
/**
|
|
1373
1176
|
* Ask for the workspace's public address to become `slug`.
|
|
1374
1177
|
*
|
|
@@ -1548,44 +1351,6 @@ async function reportCredential(creds) {
|
|
|
1548
1351
|
console.log("");
|
|
1549
1352
|
}
|
|
1550
1353
|
//#endregion
|
|
1551
|
-
//#region src/schedules.ts
|
|
1552
|
-
/**
|
|
1553
|
-
* When a schedule next runs, or why it does not run at all.
|
|
1554
|
-
*
|
|
1555
|
-
* The three ways of being off never render alike, and a schedule that is off
|
|
1556
|
-
* twice over says so: someone who reads only the pause will declare it again,
|
|
1557
|
-
* press resume, and file a bug when neither starts it.
|
|
1558
|
-
*/
|
|
1559
|
-
function whenItRuns(schedule) {
|
|
1560
|
-
const undeclared = schedule.declared === false;
|
|
1561
|
-
if (schedule.state === "suspended") {
|
|
1562
|
-
const why = "The workspace owes payment, its subscription ended, or it has spent past its plan's allowance";
|
|
1563
|
-
return undeclared ? `suspended, and not in this config file. ${why}; declare it again too` : `suspended. ${why}`;
|
|
1564
|
-
}
|
|
1565
|
-
if (schedule.state === "paused") return undeclared ? "paused, and not in this config file. Declare it again, then resume" : "paused. Resume it to run again";
|
|
1566
|
-
if (undeclared) return "not in this config file, so it is disabled. Its history is kept";
|
|
1567
|
-
if (schedule.state !== "active") return schedule.state;
|
|
1568
|
-
return schedule.next_fire_at ? `next run ${asUtc$1(schedule.next_fire_at)}` : "next run unknown";
|
|
1569
|
-
}
|
|
1570
|
-
/** `2026-08-23 03:00 UTC`, which is the zone every expression is read in. */
|
|
1571
|
-
function asUtc$1(iso) {
|
|
1572
|
-
const at = new Date(iso);
|
|
1573
|
-
if (Number.isNaN(at.getTime())) return iso;
|
|
1574
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
1575
|
-
return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())} ${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())} UTC`;
|
|
1576
|
-
}
|
|
1577
|
-
/**
|
|
1578
|
-
* The block a deploy prints for the schedules it just applied. Every schedule
|
|
1579
|
-
* appears, including the ones that are off, so putting recurring compute on the
|
|
1580
|
-
* bill — or taking it off — is never silent.
|
|
1581
|
-
*/
|
|
1582
|
-
function scheduleLines(schedules) {
|
|
1583
|
-
if (schedules.length === 0) return [];
|
|
1584
|
-
const nameWidth = Math.max(...schedules.map((s) => s.name.length));
|
|
1585
|
-
const expressionWidth = Math.max(...schedules.map((s) => s.expression.length));
|
|
1586
|
-
return [" Schedules:", ...[...schedules].sort((a, b) => a.name.localeCompare(b.name)).map((schedule) => ` ${schedule.name.padEnd(nameWidth)} ${schedule.expression.padEnd(expressionWidth)} ${whenItRuns(schedule)}`)];
|
|
1587
|
-
}
|
|
1588
|
-
//#endregion
|
|
1589
1354
|
//#region src/billing.ts
|
|
1590
1355
|
function billingPageUrl() {
|
|
1591
1356
|
const base = getDashboardUrl().replace(/\/+$/, "");
|
|
@@ -2315,84 +2080,6 @@ function widest(values) {
|
|
|
2315
2080
|
return values.reduce((longest, value) => Math.max(longest, value.length), 0);
|
|
2316
2081
|
}
|
|
2317
2082
|
//#endregion
|
|
2318
|
-
//#region src/assets.ts
|
|
2319
|
-
/**
|
|
2320
|
-
* The files under `dir`, hashed.
|
|
2321
|
-
*
|
|
2322
|
-
* Hashes are read off disk in a stream rather than by reading each file whole:
|
|
2323
|
-
* a client build's largest chunk is not something to hold in memory just to
|
|
2324
|
-
* find out the platform already has it.
|
|
2325
|
-
*/
|
|
2326
|
-
function collectAssets(dir) {
|
|
2327
|
-
if (!fs.existsSync(dir)) return [];
|
|
2328
|
-
const assets = [];
|
|
2329
|
-
const walk = (current, prefix) => {
|
|
2330
|
-
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2331
|
-
const absolute = path.join(current, entry.name);
|
|
2332
|
-
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
2333
|
-
if (entry.isDirectory()) walk(absolute, relative);
|
|
2334
|
-
else if (entry.isFile()) assets.push({
|
|
2335
|
-
path: relative,
|
|
2336
|
-
content_hash: hashFile(absolute),
|
|
2337
|
-
size_bytes: fs.statSync(absolute).size,
|
|
2338
|
-
source: absolute
|
|
2339
|
-
});
|
|
2340
|
-
}
|
|
2341
|
-
};
|
|
2342
|
-
walk(dir, "");
|
|
2343
|
-
return assets;
|
|
2344
|
-
}
|
|
2345
|
-
/**
|
|
2346
|
-
* A **Static asset** a browser renders as a page. Read off the extension, as the
|
|
2347
|
-
* content type the platform records is — mirrors `content_type_for_path` in
|
|
2348
|
-
* `server/crates/core/src/features/assets/content_type.rs`.
|
|
2349
|
-
*/
|
|
2350
|
-
function isDocument(assetPath) {
|
|
2351
|
-
const name = assetPath.split("/").pop() ?? "";
|
|
2352
|
-
const dot = name.lastIndexOf(".");
|
|
2353
|
-
const extension = dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
|
|
2354
|
-
return extension === "html" || extension === "htm";
|
|
2355
|
-
}
|
|
2356
|
-
function hashFile(file) {
|
|
2357
|
-
return sha256(fs.readFileSync(file));
|
|
2358
|
-
}
|
|
2359
|
-
function sha256(bytes) {
|
|
2360
|
-
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
2361
|
-
}
|
|
2362
|
-
/**
|
|
2363
|
-
* What this deploy *is*: the server bundle together with the sorted set of
|
|
2364
|
-
* asset path and content-hash pairs, the same set for its **Private file**s
|
|
2365
|
-
* marked apart, and the **Fallback document** it declares.
|
|
2366
|
-
*
|
|
2367
|
-
* Computed here, before anything is uploaded, so a deploy that changed nothing
|
|
2368
|
-
* is refused before a byte moves. Mirrors `deploy_digest` in
|
|
2369
|
-
* `server/crates/core/src/features/assets/identity.rs` — the gateway recomputes
|
|
2370
|
-
* it from what actually arrives, so the two have to agree exactly.
|
|
2371
|
-
*
|
|
2372
|
-
* A deploy declaring no fallback and no private files contributes nothing here
|
|
2373
|
-
* for either rather than a stated absence, so every digest computed before
|
|
2374
|
-
* they existed is unmoved.
|
|
2375
|
-
*/
|
|
2376
|
-
const DEPLOY_DIGEST_DOMAIN = "wawesome-deploy-v1";
|
|
2377
|
-
function deployDigest(bundle, assets, fallback, privateFiles = []) {
|
|
2378
|
-
const byPath = (files) => [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
2379
|
-
const digest = crypto.createHash("sha256");
|
|
2380
|
-
digest.update(`${DEPLOY_DIGEST_DOMAIN}\n`);
|
|
2381
|
-
digest.update(`${sha256(bundle)}\n`);
|
|
2382
|
-
for (const asset of byPath(assets)) digest.update(`${asset.path}\0${asset.content_hash}\n`);
|
|
2383
|
-
for (const file of byPath(privateFiles)) digest.update(`private\0${file.path}\0${file.content_hash}\n`);
|
|
2384
|
-
if (fallback) digest.update(`fallback\0${fallback.path}\0${fallback.status}\n`);
|
|
2385
|
-
return digest.digest("hex");
|
|
2386
|
-
}
|
|
2387
|
-
/** The manifest as the gateway reads it — the bytes on disk are the CLI's business. */
|
|
2388
|
-
function manifestOf(assets) {
|
|
2389
|
-
return assets.map(({ path, content_hash, size_bytes }) => ({
|
|
2390
|
-
path,
|
|
2391
|
-
content_hash,
|
|
2392
|
-
size_bytes
|
|
2393
|
-
}));
|
|
2394
|
-
}
|
|
2395
|
-
//#endregion
|
|
2396
2083
|
//#region src/prompt.ts
|
|
2397
2084
|
/**
|
|
2398
2085
|
* Open a prompt session on stdin, queueing lines as they arrive.
|
|
@@ -3379,7 +3066,7 @@ async function deploy(entryInput, options) {
|
|
|
3379
3066
|
if (assets.length > 0 || privateFiles.length > 0) await uploadAssets(creds, app, funcName, jsCode ?? "", {
|
|
3380
3067
|
assets,
|
|
3381
3068
|
privateFiles
|
|
3382
|
-
}, declared, isVerbose, declaresSchedules);
|
|
3069
|
+
}, declared, isVerbose, declaresSchedules || domainName !== void 0);
|
|
3383
3070
|
console.log(`[wawesome] Uploading code for ${app}/${funcName}...`);
|
|
3384
3071
|
const uploadUrl = `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/functions/${encodeURIComponent(funcName)}/code`;
|
|
3385
3072
|
if (isVerbose) console.log(`[wawesome:verbose] POST ${uploadUrl}`);
|
|
@@ -3387,7 +3074,7 @@ async function deploy(entryInput, options) {
|
|
|
3387
3074
|
...declaredFields(declared),
|
|
3388
3075
|
...declared.message ? { message: declared.message } : {}
|
|
3389
3076
|
};
|
|
3390
|
-
const uploadRes = assets.length > 0 || privateFiles.length > 0 || declaresSchedules || declaresFallback || Object.keys(declaredOnTheWire).length > 0 || jsCode === null ? await authorizedFetch(uploadUrl, {
|
|
3077
|
+
const uploadRes = assets.length > 0 || privateFiles.length > 0 || declaresSchedules || declaresFallback || domainName !== void 0 || Object.keys(declaredOnTheWire).length > 0 || jsCode === null ? await authorizedFetch(uploadUrl, {
|
|
3391
3078
|
method: "POST",
|
|
3392
3079
|
headers: {
|
|
3393
3080
|
Authorization: `Bearer ${creds.tenant_jwt}`,
|
|
@@ -3399,6 +3086,7 @@ async function deploy(entryInput, options) {
|
|
|
3399
3086
|
...privateFiles.length > 0 ? { files: manifestOf(privateFiles) } : {},
|
|
3400
3087
|
...declaresSchedules ? { schedules: config.schedules } : {},
|
|
3401
3088
|
...declaresFallback ? { fallback: declared.fallback } : {},
|
|
3089
|
+
...domainName !== void 0 ? { domain: domainName } : {},
|
|
3402
3090
|
...declaredOnTheWire
|
|
3403
3091
|
})
|
|
3404
3092
|
}) : await authorizedFetch(uploadUrl, {
|
|
@@ -3573,7 +3261,7 @@ async function uploadAssets(creds, app, funcName, bundle, { assets, privateFiles
|
|
|
3573
3261
|
if (manifestRes.status === 401 || manifestRes.status === 403) console.error(`[wawesome] Error: ${unauthorizedMessage(manifestRes.status, errorBody)}`);
|
|
3574
3262
|
else if (manifestRes.status === 409) {
|
|
3575
3263
|
if (continueWhenUnchanged) {
|
|
3576
|
-
console.log("[wawesome] Code and files unchanged since the last deploy; applying
|
|
3264
|
+
console.log("[wawesome] Code and files unchanged since the last deploy; applying the config file.");
|
|
3577
3265
|
return;
|
|
3578
3266
|
}
|
|
3579
3267
|
const refusal = rejectionOf(errorBody, manifestRes.status, "This deploy is already deployed.");
|
|
@@ -4574,18 +4262,19 @@ function announceUrl(surface, slug, appSlug, functionName) {
|
|
|
4574
4262
|
console.log(` ${SUBTREE_NOTE}\n`);
|
|
4575
4263
|
}
|
|
4576
4264
|
/**
|
|
4577
|
-
* Show the URL, and offer to
|
|
4265
|
+
* Show the URL, and offer to change the workspace address while a rename is free.
|
|
4578
4266
|
*
|
|
4579
|
-
* This is the last moment
|
|
4580
|
-
*
|
|
4581
|
-
* Asking here rather than at signup is the whole
|
|
4582
|
-
* it is client-facing branding, and nobody chooses
|
|
4583
|
-
* product work. The URL comes first so the offer is
|
|
4267
|
+
* This is the last moment a rename costs nothing: after the first promotion,
|
|
4268
|
+
* which the deploy at the end of this flow does, the old name redirects and
|
|
4269
|
+
* renames wait 30 days apart. Asking here rather than at signup is the whole
|
|
4270
|
+
* reason naming was deferred — it is client-facing branding, and nobody chooses
|
|
4271
|
+
* it well before seeing the product work. The URL comes first so the offer is
|
|
4272
|
+
* about something concrete.
|
|
4584
4273
|
*
|
|
4585
|
-
*
|
|
4586
|
-
* rename is
|
|
4587
|
-
*
|
|
4588
|
-
*
|
|
4274
|
+
* A published workspace is not offered one: its rename moves live links, and
|
|
4275
|
+
* `wawesome workspace rename` is where that is confirmed. Nothing here can fail
|
|
4276
|
+
* the flow: the project is already on disk and the deploy still works under the
|
|
4277
|
+
* address the workspace has.
|
|
4589
4278
|
*/
|
|
4590
4279
|
async function offerWorkspaceAddress(session, creds, tenant, appSlug, functionName) {
|
|
4591
4280
|
const current = tenant?.tenant_slug ?? creds.tenant_slug;
|
|
@@ -4597,13 +4286,14 @@ async function offerWorkspaceAddress(session, creds, tenant, appSlug, functionNa
|
|
|
4597
4286
|
console.log(" is a question for the gateway, which did not answer.\n");
|
|
4598
4287
|
return;
|
|
4599
4288
|
}
|
|
4600
|
-
if (tenant.
|
|
4601
|
-
console.log(`
|
|
4289
|
+
if (tenant.published) {
|
|
4290
|
+
console.log(` '${current}' is your workspace address. It is already live, so change it`);
|
|
4291
|
+
console.log(" with: wawesome workspace rename <name>\n");
|
|
4602
4292
|
return;
|
|
4603
4293
|
}
|
|
4604
4294
|
console.log(` '${current}' is your workspace address, the part of that URL that is`);
|
|
4605
|
-
console.log(" yours rather than this project's. Now is
|
|
4606
|
-
console.log("
|
|
4295
|
+
console.log(" yours rather than this project's. Now is a good moment to change it.");
|
|
4296
|
+
console.log(" You can change it later too, once every 30 days after your first deploy.");
|
|
4607
4297
|
console.log(" Press enter to keep it.\n");
|
|
4608
4298
|
let refusal;
|
|
4609
4299
|
for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt++) {
|
|
@@ -4620,7 +4310,7 @@ async function offerWorkspaceAddress(session, creds, tenant, appSlug, functionNa
|
|
|
4620
4310
|
} catch (err) {
|
|
4621
4311
|
console.log(`[wawesome] ${errorText(err)}`);
|
|
4622
4312
|
refusal = reasonOf(err);
|
|
4623
|
-
const { text, retryable } = renameAdvice(
|
|
4313
|
+
const { text, retryable } = renameAdvice(err);
|
|
4624
4314
|
if (text) console.log(`[wawesome] ${text}`);
|
|
4625
4315
|
if (!retryable) break;
|
|
4626
4316
|
}
|
|
@@ -5840,20 +5530,27 @@ async function showWorkspace(options) {
|
|
|
5840
5530
|
console.log(` ${SUBTREE_NOTE}`);
|
|
5841
5531
|
}
|
|
5842
5532
|
}
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
console.log(
|
|
5846
|
-
console.log("
|
|
5533
|
+
const waitUntil = renameWaitsUntil(tenant);
|
|
5534
|
+
if (waitUntil) {
|
|
5535
|
+
console.log(`\n The address can be changed again from ${asUtc$1(waitUntil)}.`);
|
|
5536
|
+
console.log(" One rename is allowed every 30 days.");
|
|
5537
|
+
} else {
|
|
5538
|
+
console.log("\n The address can be changed: \x1B[36mwawesome workspace rename <name>\x1B[0m");
|
|
5539
|
+
console.log(tenant.published ? " The old address then redirects to the new one for 90 days. One rename every 30 days." : " Rename it freely until your first deploy. After that, once every 30 days.");
|
|
5847
5540
|
}
|
|
5848
5541
|
console.log("");
|
|
5849
5542
|
}
|
|
5543
|
+
function renameWaitsUntil(tenant) {
|
|
5544
|
+
if (!tenant.renamable_at) return null;
|
|
5545
|
+
return new Date(tenant.renamable_at).getTime() > Date.now() ? tenant.renamable_at : null;
|
|
5546
|
+
}
|
|
5850
5547
|
/**
|
|
5851
5548
|
* Change the workspace's public address.
|
|
5852
5549
|
*
|
|
5853
|
-
* The
|
|
5854
|
-
* is told
|
|
5855
|
-
* handled:
|
|
5856
|
-
*
|
|
5550
|
+
* The 30-day wait is read from the gateway before asking, so a user who cannot
|
|
5551
|
+
* rename is told when they can instead of being walked into a rejection. The
|
|
5552
|
+
* rejection is still handled: another rename landing between the two calls is
|
|
5553
|
+
* something only the gateway knows.
|
|
5857
5554
|
*/
|
|
5858
5555
|
async function renameWorkspace(slug, options) {
|
|
5859
5556
|
const creds = requireCredentials$1();
|
|
@@ -5864,24 +5561,27 @@ async function renameWorkspace(slug, options) {
|
|
|
5864
5561
|
}
|
|
5865
5562
|
try {
|
|
5866
5563
|
const tenant = await fetchTenantDetails(creds);
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
console.error(
|
|
5564
|
+
const waitUntil = renameWaitsUntil(tenant);
|
|
5565
|
+
if (waitUntil) {
|
|
5566
|
+
console.error("\n[wawesome] \x1B[31mThe workspace address cannot be changed yet.\x1B[0m");
|
|
5567
|
+
console.error(`[wawesome] It was renamed less than 30 days ago. ${nextRenameAllowed(waitUntil)}`);
|
|
5870
5568
|
process.exit(1);
|
|
5871
5569
|
return;
|
|
5872
5570
|
}
|
|
5873
|
-
|
|
5874
|
-
|
|
5571
|
+
const typed = slug.trim();
|
|
5572
|
+
if (tenant.published && !options.yes) await confirmRename(tenant.tenant_slug, typed);
|
|
5573
|
+
if (options.verbose) console.log(`[wawesome:verbose] Renaming '${tenant.tenant_slug}' → '${typed}'`);
|
|
5574
|
+
const result = await renameTenantSlug(creds, typed);
|
|
5875
5575
|
console.log("\n======================================================");
|
|
5876
5576
|
console.log("✅ \x1B[32mWORKSPACE RENAMED\x1B[0m");
|
|
5877
5577
|
console.log("======================================================");
|
|
5878
5578
|
console.log(`\n Address: ${result.tenant_slug}`);
|
|
5879
|
-
console.log(` Previous: ${result.previous_slug} (no longer resolves)`);
|
|
5579
|
+
console.log(tenant.published ? ` Previous: ${result.previous_slug} (redirects to the new address for 90 days)` : ` Previous: ${result.previous_slug} (no longer resolves)`);
|
|
5880
5580
|
console.log("======================================================\n");
|
|
5881
5581
|
} catch (err) {
|
|
5882
5582
|
if (err instanceof GatewayError) {
|
|
5883
5583
|
console.error(`\n[wawesome] \x1b[31m${err.message}\x1b[0m`);
|
|
5884
|
-
const { text } = renameAdvice(err
|
|
5584
|
+
const { text } = renameAdvice(err);
|
|
5885
5585
|
if (text) console.error(`[wawesome] ${text}`);
|
|
5886
5586
|
console.error("");
|
|
5887
5587
|
process.exit(1);
|
|
@@ -5892,6 +5592,32 @@ async function renameWorkspace(slug, options) {
|
|
|
5892
5592
|
}
|
|
5893
5593
|
}
|
|
5894
5594
|
/**
|
|
5595
|
+
* Links are already out under a published address, so the new name is typed
|
|
5596
|
+
* twice, as the dashboard asks: a typo here would move every client's URL.
|
|
5597
|
+
*/
|
|
5598
|
+
async function confirmRename(current, next) {
|
|
5599
|
+
if (!isInteractive()) {
|
|
5600
|
+
console.error("\n[wawesome] \x1B[31mA rename after publishing asks for confirmation, and there is nobody to ask.\x1B[0m");
|
|
5601
|
+
console.error(`[wawesome] Pass --yes to rename '${current}' to '${next}' without being asked.`);
|
|
5602
|
+
process.exit(1);
|
|
5603
|
+
}
|
|
5604
|
+
console.log(`\n Rename '${current}' to '${next}'?`);
|
|
5605
|
+
console.log(` - The new address is '${next}'.`);
|
|
5606
|
+
console.log(` - '${current}' redirects to the new address for 90 days. After that it is held`);
|
|
5607
|
+
console.log(" for 90 more days, and only you can take it back.");
|
|
5608
|
+
console.log(" - Open preview links stop working now.\n");
|
|
5609
|
+
let retyped;
|
|
5610
|
+
try {
|
|
5611
|
+
retyped = await input({ message: "Type the new address again to confirm:" });
|
|
5612
|
+
} catch {
|
|
5613
|
+
retyped = "";
|
|
5614
|
+
}
|
|
5615
|
+
if (retyped.trim() !== next) {
|
|
5616
|
+
console.error(`[wawesome] Cancelled. The address is still '${current}'.`);
|
|
5617
|
+
process.exit(1);
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
/**
|
|
5895
5621
|
* The workspaces this person belongs to, with the one the session is in
|
|
5896
5622
|
* marked.
|
|
5897
5623
|
*
|
|
@@ -6751,7 +6477,7 @@ cli.command("domains detach <hostname>", "Stop serving the current app at a doma
|
|
|
6751
6477
|
cli.command("login", "Authenticate with the wawesome.io platform").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--gateway <url>", "Alias for --api <url>").option("--dashboard <url>", "Dashboard URL to record (default: the one the platform names)").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
|
|
6752
6478
|
cli.command("logout", "Clear stored authentication credentials").action(async () => await logout());
|
|
6753
6479
|
cli.command("whoami", "Show current login session info").action(() => whoami());
|
|
6754
|
-
cli.command("workspace [action] [name]", "Show the workspace, move between workspaces, or rename the public address").usage("workspace <action> [name]\n\nActions:\n show Show the workspace name, address, and whether it can still change\n list (ls) Show the workspaces you belong to, and which one you are in\n switch <name> Move this terminal to another workspace you belong to\n rename <name> Change the public address
|
|
6480
|
+
cli.command("workspace [action] [name]", "Show the workspace, move between workspaces, or rename the public address").usage("workspace <action> [name]\n\nActions:\n show Show the workspace name, address, and whether it can still change\n list (ls) Show the workspaces you belong to, and which one you are in\n switch <name> Move this terminal to another workspace you belong to\n rename <name> Change the public address. After your first deploy, once every 30 days").example("wawesome workspace").example("wawesome workspace list").example("wawesome workspace switch northwind").example("wawesome workspace rename northwind").option("-y, --yes", "Rename without typing the new address again").option("-v, --verbose", "Enable verbose debug output").action((action, name, options) => workspaceCommand(action, name, options));
|
|
6755
6481
|
cli.command("export [action]", "Export the whole workspace as one archive, and print the link to it").usage("export [status]\n\nWith no action, asks for an export and waits until it is ready.\n status Show the latest export, and a new link if it is ready").example("wawesome export").example("wawesome export status").option("--no-wait", "Ask for the export and return without waiting for it").option("-v, --verbose", "Enable verbose debug output").action((action, options) => exportCommand(action, options));
|
|
6756
6482
|
cli.command("templates [action]", "Browse the template catalog").usage("templates [action]\n\nActions:\n list (ls) Show every available template (default)").example("wawesome templates").example("wawesome templates list").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("-v, --verbose", "Enable verbose debug output").action((action, options) => templatesCommand(action, options));
|
|
6757
6483
|
cli.command("credentials [action] [target]", "Mint, list, regenerate, revoke and delete deploy credentials").alias("credential").usage(`credentials <action> [options]
|