@getquick/site 0.0.0-stage → 0.1.1
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 +53 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bin/gq.mjs +14 -0
- package/docs/research/ploi-api.md +2371 -0
- package/package.json +56 -4
- package/src/exec.mjs +23 -0
- package/src/index.mjs +1 -0
- package/src/ops/cli.mjs +349 -0
- package/src/ops/env.mjs +73 -0
- package/src/ops/output.mjs +62 -0
- package/src/ops/ploi-api-command.mjs +225 -0
- package/src/ops/project-context.mjs +103 -0
- package/src/ops/providers/cloudflare.mjs +95 -0
- package/src/ops/providers/github.mjs +104 -0
- package/src/ops/providers/http.mjs +31 -0
- package/src/ops/providers/ploi-catalog.mjs +49 -0
- package/src/ops/providers/ploi-endpoints.mjs +1590 -0
- package/src/ops/providers/ploi.mjs +159 -0
- package/src/ops/ui.mjs +84 -0
- package/src/run.mjs +45 -0
- package/src/version.mjs +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getquick/site",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Shared tooling for GETQUICK sites: the gq CLI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"gq": "bin/gq.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.mjs",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"docs/research/ploi-api.md",
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"check": "pnpm format:check && pnpm lint && pnpm test",
|
|
24
|
+
"format": "prettier --write .",
|
|
25
|
+
"format:check": "prettier --check .",
|
|
26
|
+
"lint": "eslint .",
|
|
27
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
28
|
+
"release:publish": "node scripts/publish.mjs"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@clack/prompts": "^1.8.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@eslint/js": "^10.0.1",
|
|
35
|
+
"eslint": "^10.0.3",
|
|
36
|
+
"globals": "^17.4.0",
|
|
37
|
+
"prettier": "^3.8.2",
|
|
38
|
+
"sigillo": "0.13.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.12.0"
|
|
42
|
+
},
|
|
43
|
+
"packageManager": "pnpm@12.6.0",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/Quick-Release/getquick-site.git"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"getquick",
|
|
53
|
+
"cloudflare",
|
|
54
|
+
"ploi",
|
|
55
|
+
"cli",
|
|
56
|
+
"operations"
|
|
57
|
+
]
|
|
58
|
+
}
|
package/src/exec.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// The real `exec` for run(): runs a command without a shell, optionally
|
|
4
|
+
// writing `input` to its stdin, and resolves with its exit code and output.
|
|
5
|
+
export function exec(command, args, { cwd, env, input } = {}) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(command, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
8
|
+
let stdout = "";
|
|
9
|
+
let stderr = "";
|
|
10
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => {
|
|
11
|
+
stdout += chunk;
|
|
12
|
+
});
|
|
13
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => {
|
|
14
|
+
stderr += chunk;
|
|
15
|
+
});
|
|
16
|
+
// A child that exits without reading its input closes the pipe (EPIPE);
|
|
17
|
+
// its exit code, not the write, is the result.
|
|
18
|
+
child.stdin.on("error", () => {});
|
|
19
|
+
child.once("error", reject);
|
|
20
|
+
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
21
|
+
child.stdin.end(input === undefined ? undefined : String(input));
|
|
22
|
+
});
|
|
23
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from "./run.mjs";
|
package/src/ops/cli.mjs
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { loadProjectContext } from "./project-context.mjs";
|
|
2
|
+
import { createCloudflareClient } from "./providers/cloudflare.mjs";
|
|
3
|
+
import { syncActionsValues } from "./providers/github.mjs";
|
|
4
|
+
import { createPloiClient } from "./providers/ploi.mjs";
|
|
5
|
+
import { printValue } from "./output.mjs";
|
|
6
|
+
import {
|
|
7
|
+
isPloiApiCommand,
|
|
8
|
+
runPloiApiCatalogCommand,
|
|
9
|
+
runPloiApiCommand,
|
|
10
|
+
validatePloiApiCommand,
|
|
11
|
+
} from "./ploi-api-command.mjs";
|
|
12
|
+
import { PLOI_ENDPOINTS } from "./providers/ploi-endpoints.mjs";
|
|
13
|
+
import { chooseCommand } from "./ui.mjs";
|
|
14
|
+
import { VERSION } from "../version.mjs";
|
|
15
|
+
|
|
16
|
+
const COMMAND_OPTIONS = new Map([
|
|
17
|
+
["context show", []],
|
|
18
|
+
["github actions sync", ["yes", "dryRun"]],
|
|
19
|
+
["ploi servers list", []],
|
|
20
|
+
["ploi server show", ["server"]],
|
|
21
|
+
["ploi sites list", ["server"]],
|
|
22
|
+
["ploi site show", ["server", "site"]],
|
|
23
|
+
["cloudflare accounts list", []],
|
|
24
|
+
["cloudflare zones list", ["account"]],
|
|
25
|
+
["cloudflare zone show", ["account", "zone"]],
|
|
26
|
+
["cloudflare dns list", ["account", "zone", "name", "type"]],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export async function runCli(argv, { cwd, env, fetch, exec, io, interactive }) {
|
|
30
|
+
let effectiveArguments = argv;
|
|
31
|
+
if (effectiveArguments.length === 0 && interactive) {
|
|
32
|
+
const selected = await chooseCommand(PLOI_ENDPOINTS);
|
|
33
|
+
if (!selected) return;
|
|
34
|
+
effectiveArguments = selected;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const parsed = parseArguments(effectiveArguments);
|
|
38
|
+
if (parsed.version) {
|
|
39
|
+
io.out(VERSION);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (parsed.help || parsed.command.length === 0) {
|
|
43
|
+
printHelp(io);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
validateCommand(parsed);
|
|
47
|
+
if (isPloiApiCommand(parsed.command) && runPloiApiCatalogCommand(parsed, io)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const context = await loadProjectContext({
|
|
52
|
+
cwd,
|
|
53
|
+
env,
|
|
54
|
+
project: parsed.project,
|
|
55
|
+
config: parsed.config,
|
|
56
|
+
});
|
|
57
|
+
const [provider, resource, action = "list"] = parsed.command;
|
|
58
|
+
|
|
59
|
+
if (provider === "context" && resource === "show") {
|
|
60
|
+
printValue(io, contextSummary(context), parsed);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (provider === "github" && resource === "actions" && action === "sync") {
|
|
65
|
+
printValue(
|
|
66
|
+
io,
|
|
67
|
+
await syncActionsValues({
|
|
68
|
+
context,
|
|
69
|
+
env,
|
|
70
|
+
exec,
|
|
71
|
+
dryRun: parsed.dryRun,
|
|
72
|
+
interactive,
|
|
73
|
+
yes: parsed.yes,
|
|
74
|
+
}),
|
|
75
|
+
parsed,
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (provider === "ploi") {
|
|
81
|
+
if (resource === "api") {
|
|
82
|
+
await runPloiApiCommand({ context, parsed, fetch, io, interactive });
|
|
83
|
+
} else {
|
|
84
|
+
await runPloi({ context, resource, action, parsed, fetch, io });
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (provider === "cloudflare") {
|
|
90
|
+
await runCloudflare({ context, resource, action, parsed, fetch, io });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(`Unknown command: ${parsed.command.join(" ")}. Run gq --help.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runPloi({ context, resource, action, parsed, fetch, io }) {
|
|
98
|
+
const client = createPloiClient({
|
|
99
|
+
token: context.env.PLOI_API_TOKEN,
|
|
100
|
+
fetchImplementation: fetch,
|
|
101
|
+
});
|
|
102
|
+
const serverId = parsed.server || context.env.PLOI_SERVER_ID || context.config.ploi?.serverId;
|
|
103
|
+
const siteId = parsed.site || context.env.PLOI_SITE_ID || context.config.ploi?.siteId;
|
|
104
|
+
let result;
|
|
105
|
+
|
|
106
|
+
if (resource === "servers" && action === "list") {
|
|
107
|
+
result = (await client.listServers()).map(presentPloiServer);
|
|
108
|
+
} else if (resource === "server" && action === "show") {
|
|
109
|
+
result = presentPloiServer(await client.getServer(required(serverId, "Ploi server ID")));
|
|
110
|
+
} else if (resource === "sites" && action === "list") {
|
|
111
|
+
result = (await client.listSites(required(serverId, "Ploi server ID"))).map(presentPloiSite);
|
|
112
|
+
} else if (resource === "site" && action === "show") {
|
|
113
|
+
result = presentPloiSite(
|
|
114
|
+
await client.getSite(required(serverId, "Ploi server ID"), required(siteId, "Ploi site ID")),
|
|
115
|
+
);
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error(`Unknown Ploi command: ${resource || ""} ${action}. Run gq --help.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
printValue(io, result, parsed);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function runCloudflare({ context, resource, action, parsed, fetch, io }) {
|
|
124
|
+
const client = createCloudflareClient({
|
|
125
|
+
token: context.env.CLOUDFLARE_API_TOKEN,
|
|
126
|
+
fetchImplementation: fetch,
|
|
127
|
+
});
|
|
128
|
+
const accountId =
|
|
129
|
+
parsed.account || context.env.CLOUDFLARE_ACCOUNT_ID || context.config.cloudflare?.accountId;
|
|
130
|
+
let zoneId = parsed.zone || context.env.CLOUDFLARE_ZONE_ID || context.config.cloudflare?.zoneId;
|
|
131
|
+
const zoneName = context.env.CLOUDFLARE_ZONE_NAME || context.config.cloudflare?.zoneName;
|
|
132
|
+
let result;
|
|
133
|
+
|
|
134
|
+
if (resource === "accounts" && action === "list") {
|
|
135
|
+
result = (await client.listAccounts()).map(presentCloudflareAccount);
|
|
136
|
+
} else if (resource === "zones" && action === "list") {
|
|
137
|
+
result = (await client.listZones({ "account.id": accountId, name: zoneName })).map(
|
|
138
|
+
presentCloudflareZone,
|
|
139
|
+
);
|
|
140
|
+
} else if (resource === "zone" && action === "show") {
|
|
141
|
+
zoneId = await resolveZoneId(client, { zoneId, zoneName, accountId });
|
|
142
|
+
result = presentCloudflareZone(await client.getZone(zoneId));
|
|
143
|
+
} else if (resource === "dns" && action === "list") {
|
|
144
|
+
zoneId = await resolveZoneId(client, { zoneId, zoneName, accountId });
|
|
145
|
+
result = (
|
|
146
|
+
await client.listDnsRecords(zoneId, {
|
|
147
|
+
name: parsed.name,
|
|
148
|
+
type: parsed.type,
|
|
149
|
+
})
|
|
150
|
+
).map(presentDnsRecord);
|
|
151
|
+
} else {
|
|
152
|
+
throw new Error(`Unknown Cloudflare command: ${resource || ""} ${action}. Run gq --help.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
printValue(io, result, parsed);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolveZoneId(client, { zoneId, zoneName, accountId }) {
|
|
159
|
+
if (zoneId) return zoneId;
|
|
160
|
+
const zones = await client.listZones({
|
|
161
|
+
name: zoneName,
|
|
162
|
+
"account.id": accountId,
|
|
163
|
+
});
|
|
164
|
+
if (zones.length !== 1) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Expected exactly one matching Cloudflare zone, found ${zones.length}. Configure cloudflare.zoneId or pass --zone.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return zones[0].id;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseArguments(argv) {
|
|
173
|
+
const parsed = { command: [] };
|
|
174
|
+
const valueOptions = new Set([
|
|
175
|
+
"--project",
|
|
176
|
+
"--config",
|
|
177
|
+
"--server",
|
|
178
|
+
"--site",
|
|
179
|
+
"--account",
|
|
180
|
+
"--zone",
|
|
181
|
+
"--name",
|
|
182
|
+
"--type",
|
|
183
|
+
"--group",
|
|
184
|
+
"--search",
|
|
185
|
+
"--path",
|
|
186
|
+
"--query",
|
|
187
|
+
"--data",
|
|
188
|
+
"--data-file",
|
|
189
|
+
"--page",
|
|
190
|
+
"--per-page",
|
|
191
|
+
"--max-pages",
|
|
192
|
+
]);
|
|
193
|
+
const repeatedValueOptions = new Set(["--path", "--query"]);
|
|
194
|
+
const booleanOptions = new Set(["--all", "--dry-run", "--yes"]);
|
|
195
|
+
|
|
196
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
197
|
+
const argument = argv[index];
|
|
198
|
+
if (argument === "--help" || argument === "-h" || argument === "help") parsed.help = true;
|
|
199
|
+
else if (argument === "--version" || argument === "-v") parsed.version = true;
|
|
200
|
+
else if (argument === "--json") parsed.json = true;
|
|
201
|
+
else if (booleanOptions.has(argument)) parsed[toOptionKey(argument)] = true;
|
|
202
|
+
else if (valueOptions.has(argument)) {
|
|
203
|
+
const value = argv[index + 1];
|
|
204
|
+
if (!value || value.startsWith("--")) throw new Error(`${argument} requires a value.`);
|
|
205
|
+
const key = toOptionKey(argument);
|
|
206
|
+
if (repeatedValueOptions.has(argument)) {
|
|
207
|
+
parsed[key] ||= [];
|
|
208
|
+
parsed[key].push(value);
|
|
209
|
+
} else {
|
|
210
|
+
if (parsed[key] !== undefined) throw new Error(`${argument} may only be provided once.`);
|
|
211
|
+
parsed[key] = value;
|
|
212
|
+
}
|
|
213
|
+
index += 1;
|
|
214
|
+
} else if (argument.startsWith("--")) throw new Error(`Unknown option: ${argument}.`);
|
|
215
|
+
else parsed.command.push(argument);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return parsed;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function toOptionKey(argument) {
|
|
222
|
+
return argument.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function validateCommand(parsed) {
|
|
226
|
+
if (isPloiApiCommand(parsed.command)) {
|
|
227
|
+
validatePloiApiCommand(parsed);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const command = parsed.command.join(" ");
|
|
232
|
+
const allowedOptions = COMMAND_OPTIONS.get(command);
|
|
233
|
+
if (!allowedOptions) throw new Error(`Unknown command: ${command}. Run gq --help.`);
|
|
234
|
+
|
|
235
|
+
const globals = new Set(["command", "help", "json", "project", "config"]);
|
|
236
|
+
for (const key of Object.keys(parsed)) {
|
|
237
|
+
if (!globals.has(key) && !allowedOptions.includes(key)) {
|
|
238
|
+
throw new Error(`--${toFlagName(key)} is not valid for ${command}.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function toFlagName(key) {
|
|
244
|
+
return key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function contextSummary(context) {
|
|
248
|
+
return {
|
|
249
|
+
project: context.config.project,
|
|
250
|
+
projectRoot: context.projectRoot,
|
|
251
|
+
configPath: context.configPath,
|
|
252
|
+
invocationDirectory: context.invocationDirectory,
|
|
253
|
+
machineEnvPath: context.machineEnvPath,
|
|
254
|
+
ploiConfigured: Boolean(context.env.PLOI_API_TOKEN),
|
|
255
|
+
cloudflareConfigured: Boolean(context.env.CLOUDFLARE_API_TOKEN),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function presentPloiServer(server) {
|
|
260
|
+
return {
|
|
261
|
+
id: server.id,
|
|
262
|
+
name: server.name,
|
|
263
|
+
status: server.status,
|
|
264
|
+
ip: server.ip_address,
|
|
265
|
+
provider: server.provider,
|
|
266
|
+
region: server.region,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function presentPloiSite(site) {
|
|
271
|
+
return {
|
|
272
|
+
id: site.id,
|
|
273
|
+
domain: site.domain,
|
|
274
|
+
status: site.status,
|
|
275
|
+
systemUser: site.system_user,
|
|
276
|
+
projectRoot: site.project_root,
|
|
277
|
+
webDirectory: site.web_directory,
|
|
278
|
+
lastDeployAt: site.last_deploy_at,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function presentCloudflareAccount(account) {
|
|
283
|
+
return { id: account.id, name: account.name, type: account.type };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function presentCloudflareZone(zone) {
|
|
287
|
+
return {
|
|
288
|
+
id: zone.id,
|
|
289
|
+
name: zone.name,
|
|
290
|
+
status: zone.status,
|
|
291
|
+
account: zone.account?.name,
|
|
292
|
+
accountId: zone.account?.id,
|
|
293
|
+
plan: zone.plan?.name,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function presentDnsRecord(record) {
|
|
298
|
+
return {
|
|
299
|
+
id: record.id,
|
|
300
|
+
type: record.type,
|
|
301
|
+
name: record.name,
|
|
302
|
+
content: record.content,
|
|
303
|
+
proxied: record.proxied,
|
|
304
|
+
ttl: record.ttl,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function required(value, label) {
|
|
309
|
+
if (value === undefined || value === null || String(value).trim() === "") {
|
|
310
|
+
throw new Error(`${label} is required in gq.ops.json, .env, or a CLI flag.`);
|
|
311
|
+
}
|
|
312
|
+
return String(value).trim();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function printHelp(io) {
|
|
316
|
+
io.out(`gq operations CLI
|
|
317
|
+
|
|
318
|
+
Usage:
|
|
319
|
+
gq [global options] <command> [command options]
|
|
320
|
+
|
|
321
|
+
Project:
|
|
322
|
+
gq context show
|
|
323
|
+
gq github actions sync [--dry-run] [--yes]
|
|
324
|
+
|
|
325
|
+
Ploi:
|
|
326
|
+
gq ploi servers list
|
|
327
|
+
gq ploi server show [--server <id>]
|
|
328
|
+
gq ploi sites list [--server <id>]
|
|
329
|
+
gq ploi site show [--server <id>] [--site <id>]
|
|
330
|
+
gq ploi api list [--group <group>] [--search <text>]
|
|
331
|
+
gq ploi api describe <operation-id>
|
|
332
|
+
gq ploi api <operation-id> [--path <name=value>] [--query <name=value>]
|
|
333
|
+
[--data <json> | --data-file <file>] [--all] [--dry-run | --yes]
|
|
334
|
+
|
|
335
|
+
Cloudflare:
|
|
336
|
+
gq cloudflare accounts list
|
|
337
|
+
gq cloudflare zones list [--account <id>]
|
|
338
|
+
gq cloudflare zone show [--zone <id>]
|
|
339
|
+
gq cloudflare dns list [--zone <id>] [--name <hostname>] [--type <type>]
|
|
340
|
+
|
|
341
|
+
Global options:
|
|
342
|
+
--project <directory> Select a project explicitly
|
|
343
|
+
--config <file> Select a configuration file explicitly
|
|
344
|
+
--json Print machine-readable JSON
|
|
345
|
+
--version, -v Show the CLI version
|
|
346
|
+
--help, -h Show this help
|
|
347
|
+
|
|
348
|
+
The CLI searches upward from the current directory for gq.ops.json.`);
|
|
349
|
+
}
|
package/src/ops/env.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { chmod, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export function parseEnv(content) {
|
|
4
|
+
const values = {};
|
|
5
|
+
|
|
6
|
+
for (const line of content.split(/\r?\n/)) {
|
|
7
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
8
|
+
if (!match) continue;
|
|
9
|
+
values[match[1]] = parseValue(match[2]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return values;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readEnvFile(path) {
|
|
16
|
+
try {
|
|
17
|
+
return parseEnv(await readFile(path, "utf8"));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error?.code === "ENOENT") return {};
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function mergeEnvFile(path, updates) {
|
|
25
|
+
let content = "";
|
|
26
|
+
try {
|
|
27
|
+
content = await readFile(path, "utf8");
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error?.code !== "ENOENT") throw error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const lines = content.split(/\r?\n/).map((line) => {
|
|
34
|
+
const match = line.match(/^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*).*$/);
|
|
35
|
+
if (!match || !(match[2] in updates)) return line;
|
|
36
|
+
seen.add(match[2]);
|
|
37
|
+
return `${match[1]}${match[2]}${match[3]}${formatValue(updates[match[2]])}`;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const [name, value] of Object.entries(updates)) {
|
|
41
|
+
if (!seen.has(name)) lines.push(`${name}=${formatValue(value)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await writeFile(path, `${lines.join("\n").replace(/\n*$/, "")}\n`, {
|
|
45
|
+
mode: 0o600,
|
|
46
|
+
});
|
|
47
|
+
await chmod(path, 0o600);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatValue(value) {
|
|
51
|
+
const stringValue = String(value ?? "");
|
|
52
|
+
if (stringValue === "" || /^[A-Za-z0-9_./:@-]+$/.test(stringValue)) {
|
|
53
|
+
return stringValue;
|
|
54
|
+
}
|
|
55
|
+
return `"${stringValue.replace(/["\\$`]/g, "\\$&")}"`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseValue(value) {
|
|
59
|
+
const trimmed = value.trim();
|
|
60
|
+
if (trimmed.startsWith("'")) {
|
|
61
|
+
const closing = trimmed.indexOf("'", 1);
|
|
62
|
+
if (closing !== -1) return trimmed.slice(1, closing);
|
|
63
|
+
}
|
|
64
|
+
if (trimmed.startsWith('"')) {
|
|
65
|
+
const match = trimmed.match(/^"((?:\\.|[^"\\])*)"/);
|
|
66
|
+
if (match) {
|
|
67
|
+
return match[1].replace(/\\([nrt"\\])/g, (_, escaped) => {
|
|
68
|
+
return { n: "\n", r: "\r", t: "\t", '"': '"', "\\": "\\" }[escaped];
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return trimmed.replace(/\s+#.*$/, "").trim();
|
|
73
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Writes lines through io.out, the run() seam's stdout.
|
|
2
|
+
export function printValue(io, value, options = {}) {
|
|
3
|
+
if (options.json) {
|
|
4
|
+
io.out(JSON.stringify(value, null, 2));
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
printTable(io, value);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
printObject(io, value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function printObject(io, value) {
|
|
17
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
18
|
+
if (entry === undefined || entry === null || typeof entry === "object") continue;
|
|
19
|
+
io.out(`${humanize(key)}: ${sanitize(entry)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function printTable(io, rows) {
|
|
24
|
+
if (rows.length === 0) {
|
|
25
|
+
io.out("No results.");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const keys = uniqueKeys(rows);
|
|
30
|
+
const widths = keys.map((key) =>
|
|
31
|
+
Math.max(key.length, ...rows.map((row) => formatCell(row[key]).length)),
|
|
32
|
+
);
|
|
33
|
+
io.out(keys.map((key, index) => key.toUpperCase().padEnd(widths[index])).join(" "));
|
|
34
|
+
io.out(widths.map((width) => "-".repeat(width)).join(" "));
|
|
35
|
+
for (const row of rows) {
|
|
36
|
+
io.out(keys.map((key, index) => formatCell(row[key]).padEnd(widths[index])).join(" "));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function uniqueKeys(rows) {
|
|
41
|
+
return [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatCell(value) {
|
|
45
|
+
if (value === undefined || value === null) return "";
|
|
46
|
+
if (typeof value === "object") return sanitize(JSON.stringify(value));
|
|
47
|
+
return sanitize(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitize(value) {
|
|
51
|
+
return [...String(value)]
|
|
52
|
+
.map((character) => {
|
|
53
|
+
const codePoint = character.codePointAt(0);
|
|
54
|
+
const isControl = codePoint <= 31 || (codePoint >= 127 && codePoint <= 159);
|
|
55
|
+
return isControl ? `\\u${codePoint.toString(16).padStart(4, "0")}` : character;
|
|
56
|
+
})
|
|
57
|
+
.join("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function humanize(value) {
|
|
61
|
+
return value.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (letter) => letter.toUpperCase());
|
|
62
|
+
}
|