hollware 0.0.1 → 0.1.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 +50 -11
- package/dist/args-CljWh-kZ.mjs +20 -0
- package/dist/d1-Bd-sdoDd.mjs +281 -0
- package/dist/entropy-CIYbTHkj.mjs +17 -0
- package/dist/format-BpRYEsGB.mjs +38 -0
- package/dist/import-Dlpn_w3s.mjs +506 -0
- package/dist/index.d.mts +49 -8
- package/dist/index.mjs +75 -28
- package/dist/init-Cl9Epsbp.mjs +108 -0
- package/dist/migrate-CLHWuUje.mjs +118 -0
- package/dist/token-BLkrPLio.mjs +228 -0
- package/dist/version-BMhdZaNo.mjs +9 -0
- package/dist/wrangler-BLIfwbcu.mjs +106 -0
- package/package.json +9 -2
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { i as UsageError, r as CliError, t as printable } from "./format-BpRYEsGB.mjs";
|
|
3
|
+
import { t as readVersion } from "./version-BMhdZaNo.mjs";
|
|
4
|
+
import { t as createWranglerRunner } from "./wrangler-BLIfwbcu.mjs";
|
|
5
|
+
import { mkdtempSync, realpathSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
3
8
|
import { pathToFileURL } from "node:url";
|
|
4
9
|
//#region src/index.ts
|
|
5
10
|
const COMMANDS = [
|
|
6
11
|
{
|
|
7
12
|
name: "init",
|
|
8
|
-
summary: "Set up
|
|
13
|
+
summary: "Set up a site, its owner and a first token in the project's D1 database"
|
|
9
14
|
},
|
|
10
15
|
{
|
|
11
16
|
name: "migrate",
|
|
@@ -13,7 +18,7 @@ const COMMANDS = [
|
|
|
13
18
|
},
|
|
14
19
|
{
|
|
15
20
|
name: "token",
|
|
16
|
-
summary: "Create, list and revoke
|
|
21
|
+
summary: "Create, list and revoke personal tokens"
|
|
17
22
|
},
|
|
18
23
|
{
|
|
19
24
|
name: "import",
|
|
@@ -21,34 +26,36 @@ const COMMANDS = [
|
|
|
21
26
|
},
|
|
22
27
|
{
|
|
23
28
|
name: "export",
|
|
24
|
-
summary: "Export entries as Markdown files"
|
|
29
|
+
summary: "Export entries as Markdown files (planned for v0.2, not implemented yet)"
|
|
25
30
|
}
|
|
26
31
|
];
|
|
27
32
|
const NAME_WIDTH = Math.max(...COMMANDS.map((command) => command.name.length));
|
|
28
33
|
const USAGE = [
|
|
29
|
-
"Usage: hollware <command>",
|
|
30
|
-
"",
|
|
31
|
-
"Pre-alpha: no command is implemented yet.",
|
|
34
|
+
"Usage: hollware <command> [options]",
|
|
32
35
|
"",
|
|
33
36
|
"Commands:",
|
|
34
|
-
...COMMANDS.map(({ name, summary }) => ` ${name.padEnd(NAME_WIDTH)} ${summary}
|
|
37
|
+
...COMMANDS.map(({ name, summary }) => ` ${name.padEnd(NAME_WIDTH)} ${summary}`),
|
|
35
38
|
"",
|
|
36
39
|
"Options:",
|
|
37
40
|
" --help Show this help",
|
|
38
|
-
" --version Print the version"
|
|
41
|
+
" --version Print the version",
|
|
42
|
+
"",
|
|
43
|
+
"Run \"hollware <command> --help\" for the options of a command.",
|
|
44
|
+
"init, migrate and token work on the project in the current directory, through its wrangler."
|
|
39
45
|
].join("\n");
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
const TOKEN_COMMANDS = [
|
|
47
|
+
"create",
|
|
48
|
+
"list",
|
|
49
|
+
"revoke"
|
|
50
|
+
];
|
|
45
51
|
/**
|
|
46
|
-
* Runs the CLI and returns its exit code. Each call to `io.out` or `io.err` is one
|
|
47
|
-
*
|
|
48
|
-
*
|
|
52
|
+
* Runs the CLI and returns its exit code. Each call to `io.out` or `io.err` is one message;
|
|
53
|
+
* whoever owns the streams adds the trailing newline. The command is the first argument; the
|
|
54
|
+
* rest are its own. A `CliError` becomes one message on stderr and the exit code 1, and
|
|
55
|
+
* anything else is a bug, which escapes.
|
|
49
56
|
*/
|
|
50
|
-
function main(argv, io) {
|
|
51
|
-
const [first] = argv;
|
|
57
|
+
async function main(argv, io) {
|
|
58
|
+
const [first, ...rest] = argv;
|
|
52
59
|
if (first === void 0 || first === "--help") {
|
|
53
60
|
io.out(USAGE);
|
|
54
61
|
return 0;
|
|
@@ -57,12 +64,36 @@ function main(argv, io) {
|
|
|
57
64
|
io.out(`hollware ${readVersion()}`);
|
|
58
65
|
return 0;
|
|
59
66
|
}
|
|
60
|
-
if (
|
|
61
|
-
io.err(
|
|
67
|
+
if (first === "export") {
|
|
68
|
+
io.err("hollware export: not implemented yet (planned for v0.2)");
|
|
62
69
|
return 1;
|
|
63
70
|
}
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
if (![
|
|
72
|
+
"init",
|
|
73
|
+
"migrate",
|
|
74
|
+
"token",
|
|
75
|
+
"import"
|
|
76
|
+
].includes(first)) {
|
|
77
|
+
io.err(`hollware: unknown command or flag "${printable(first)}"\n\n${USAGE}`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
const label = first === "token" && TOKEN_COMMANDS.includes(rest[0] ?? "") ? `token ${rest[0]}` : first;
|
|
81
|
+
try {
|
|
82
|
+
return await run(first, rest, io);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (!(error instanceof CliError)) throw error;
|
|
85
|
+
const hint = error instanceof UsageError ? ` Run "hollware ${label} --help" for the options.` : "";
|
|
86
|
+
io.err(`hollware ${label}: ${error.message}${hint}`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function run(command, args, io) {
|
|
91
|
+
switch (command) {
|
|
92
|
+
case "init": return (await import("./init-Cl9Epsbp.mjs")).init(args, io);
|
|
93
|
+
case "migrate": return (await import("./migrate-CLHWuUje.mjs")).migrate(args, io);
|
|
94
|
+
case "token": return (await import("./token-BLkrPLio.mjs")).token(args, io);
|
|
95
|
+
default: return (await import("./import-Dlpn_w3s.mjs")).importContent(args, io);
|
|
96
|
+
}
|
|
66
97
|
}
|
|
67
98
|
function isEntryPoint() {
|
|
68
99
|
const entry = process.argv[1];
|
|
@@ -73,9 +104,25 @@ function isEntryPoint() {
|
|
|
73
104
|
return false;
|
|
74
105
|
}
|
|
75
106
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
107
|
+
async function readStdin() {
|
|
108
|
+
if (process.stdin.isTTY) return void 0;
|
|
109
|
+
const chunks = [];
|
|
110
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
111
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
112
|
+
}
|
|
113
|
+
function processIo() {
|
|
114
|
+
const cwd = process.cwd();
|
|
115
|
+
return {
|
|
116
|
+
out: (message) => process.stdout.write(`${message}\n`),
|
|
117
|
+
err: (message) => process.stderr.write(`${message}\n`),
|
|
118
|
+
cwd,
|
|
119
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
120
|
+
randomBytes: (length) => crypto.getRandomValues(new Uint8Array(length)),
|
|
121
|
+
readStdin,
|
|
122
|
+
wrangler: createWranglerRunner(cwd),
|
|
123
|
+
tempDir: () => mkdtempSync(join(tmpdir(), "hollware-"))
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (isEntryPoint()) process.exitCode = await main(process.argv.slice(2), processIo());
|
|
80
127
|
//#endregion
|
|
81
|
-
export { main };
|
|
128
|
+
export { createWranglerRunner, main };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { i as UsageError, r as CliError } from "./format-BpRYEsGB.mjs";
|
|
2
|
+
import { t as parseFlags } from "./args-CljWh-kZ.mjs";
|
|
3
|
+
import { a as runFile, f as parseOwnerName, i as resolveDatabase, l as ownerQuery, m as parseSiteKey, n as describeDatabase, o as initScript, r as query, t as chooseTarget } from "./d1-Bd-sdoDd.mjs";
|
|
4
|
+
import { n as newId, r as newPersonalToken, t as inDays } from "./entropy-CIYbTHkj.mjs";
|
|
5
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { PERSONAL_TOKEN_TTL_DAYS, SCOPES, hashPersonalToken } from "@hollware/core";
|
|
8
|
+
//#region src/commands/init.ts
|
|
9
|
+
const INIT_USAGE = [
|
|
10
|
+
"Usage: hollware init --site <key> [--local | --remote] [--owner-name <name>] [--database <name>]",
|
|
11
|
+
"",
|
|
12
|
+
"Creates a site, its owner and the owner's first personal token in the project's D1 database,",
|
|
13
|
+
"in one step, and prints the token once. It does nothing when the database already has an owner.",
|
|
14
|
+
"Run \"hollware migrate\" first: the database needs its tables.",
|
|
15
|
+
"",
|
|
16
|
+
"Options:",
|
|
17
|
+
" --site <key> The site, by the key it has in hollware.config.ts (required)",
|
|
18
|
+
" --local Use the local database (the default)",
|
|
19
|
+
" --remote Use the database in Cloudflare",
|
|
20
|
+
" --owner-name <name> The name of the owner",
|
|
21
|
+
" --database <name> The D1 database, by name or binding (default: the binding \"DB\" of wrangler.jsonc)",
|
|
22
|
+
" --help Show this help"
|
|
23
|
+
].join("\n");
|
|
24
|
+
/** The label of the token `init` creates. */
|
|
25
|
+
const FIRST_TOKEN_LABEL = "hollware init";
|
|
26
|
+
async function init(argv, io) {
|
|
27
|
+
const { values } = parseFlags({
|
|
28
|
+
args: [...argv],
|
|
29
|
+
allowPositionals: false,
|
|
30
|
+
options: {
|
|
31
|
+
site: { type: "string" },
|
|
32
|
+
local: { type: "boolean" },
|
|
33
|
+
remote: { type: "boolean" },
|
|
34
|
+
"owner-name": { type: "string" },
|
|
35
|
+
database: { type: "string" },
|
|
36
|
+
help: { type: "boolean" }
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
if (values.help === true) {
|
|
40
|
+
io.out(INIT_USAGE);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
if (values.site === void 0) throw new UsageError("--site is required: name the site, as in hollware.config.ts.");
|
|
44
|
+
const site = parseSiteKey(values.site);
|
|
45
|
+
const ownerName = values["owner-name"] === void 0 ? void 0 : parseOwnerName(values["owner-name"]);
|
|
46
|
+
const target = chooseTarget(values);
|
|
47
|
+
const database = resolveDatabase(io.cwd, values.database);
|
|
48
|
+
const [owners = []] = await query(io.wrangler, database, target, ownerQuery());
|
|
49
|
+
const [existing] = owners;
|
|
50
|
+
if (existing !== void 0) {
|
|
51
|
+
const id = typeof existing.id === "string" ? ` (${existing.id})` : "";
|
|
52
|
+
throw new CliError(`the database already has an owner${id}, so there is nothing to set up. Create another token with "hollware token create --label <text>".`);
|
|
53
|
+
}
|
|
54
|
+
const now = io.now();
|
|
55
|
+
const timestamp = now.toISOString();
|
|
56
|
+
const ownerId = newId(io, "user");
|
|
57
|
+
const tokenId = newId(io, "token");
|
|
58
|
+
const secret = newPersonalToken(io);
|
|
59
|
+
const expiresAt = inDays(now, PERSONAL_TOKEN_TTL_DAYS);
|
|
60
|
+
const script = initScript({
|
|
61
|
+
site,
|
|
62
|
+
ownerId,
|
|
63
|
+
ownerName,
|
|
64
|
+
token: {
|
|
65
|
+
id: tokenId,
|
|
66
|
+
site,
|
|
67
|
+
userId: ownerId,
|
|
68
|
+
label: FIRST_TOKEN_LABEL,
|
|
69
|
+
hash: await hashPersonalToken(secret),
|
|
70
|
+
scopes: SCOPES,
|
|
71
|
+
expiresAt,
|
|
72
|
+
now: timestamp
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
const directory = io.tempDir();
|
|
76
|
+
try {
|
|
77
|
+
const file = join(directory, "init.sql");
|
|
78
|
+
writeFileSync(file, script, { mode: 384 });
|
|
79
|
+
await runFile(io.wrangler, database, target, file);
|
|
80
|
+
} finally {
|
|
81
|
+
rmSync(directory, {
|
|
82
|
+
recursive: true,
|
|
83
|
+
force: true
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
io.out([
|
|
87
|
+
`Hollware is ready on ${describeDatabase(target, database)}.`,
|
|
88
|
+
"",
|
|
89
|
+
`Site ${site}`,
|
|
90
|
+
`Owner ${ownerId}`,
|
|
91
|
+
`Token ${tokenId}`,
|
|
92
|
+
`Scopes ${SCOPES.join(" ")}`,
|
|
93
|
+
`Expires ${expiresAt} (in ${PERSONAL_TOKEN_TTL_DAYS} days)`,
|
|
94
|
+
"",
|
|
95
|
+
"Your personal token. It is shown only once: Hollware keeps just its hash.",
|
|
96
|
+
"",
|
|
97
|
+
secret,
|
|
98
|
+
"",
|
|
99
|
+
"Next:",
|
|
100
|
+
" Save the token in a file only you can read, then import content:",
|
|
101
|
+
" hollware import <directory> --url <your Hollware URL> --token-file <file>",
|
|
102
|
+
" An MCP client sends it as the header \"Authorization: Bearer <token>\" to <your Hollware URL>/mcp.",
|
|
103
|
+
" More tokens: hollware token create --label <text>"
|
|
104
|
+
].join("\n"));
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { init };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { r as CliError } from "./format-BpRYEsGB.mjs";
|
|
2
|
+
import { n as failureReason, r as stripAnsi } from "./wrangler-BLIfwbcu.mjs";
|
|
3
|
+
import { t as parseFlags } from "./args-CljWh-kZ.mjs";
|
|
4
|
+
import { i as resolveDatabase, n as describeDatabase, t as chooseTarget } from "./d1-Bd-sdoDd.mjs";
|
|
5
|
+
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
//#region src/commands/migrate.ts
|
|
8
|
+
const MIGRATE_USAGE = [
|
|
9
|
+
"Usage: hollware migrate [--local | --remote] [--check] [--database <name>]",
|
|
10
|
+
"",
|
|
11
|
+
"Applies the D1 migrations the project has not applied yet, with wrangler. Before it applies",
|
|
12
|
+
"anything to the remote database it exports a backup, and it stops if the backup fails. The",
|
|
13
|
+
"export can make the database unavailable for a moment; with nothing pending there is no backup.",
|
|
14
|
+
"",
|
|
15
|
+
"Options:",
|
|
16
|
+
" --local Use the local database (the default)",
|
|
17
|
+
" --remote Use the database in Cloudflare, after a backup in .wrangler/hollware-backups",
|
|
18
|
+
" --check Only list the pending migrations; exit with code 1 when there are some",
|
|
19
|
+
" --database <name> The D1 database, by name or binding (default: the binding \"DB\" of wrangler.jsonc)",
|
|
20
|
+
" --help Show this help"
|
|
21
|
+
].join("\n");
|
|
22
|
+
/** Where the backups before a remote migration go, below the project: wrangler's own, git-ignored folder. */
|
|
23
|
+
const BACKUP_DIRECTORY = join(".wrangler", "hollware-backups");
|
|
24
|
+
async function migrate(argv, io) {
|
|
25
|
+
const { values } = parseFlags({
|
|
26
|
+
args: [...argv],
|
|
27
|
+
allowPositionals: false,
|
|
28
|
+
options: {
|
|
29
|
+
local: { type: "boolean" },
|
|
30
|
+
remote: { type: "boolean" },
|
|
31
|
+
check: { type: "boolean" },
|
|
32
|
+
database: { type: "string" },
|
|
33
|
+
help: { type: "boolean" }
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
if (values.help === true) {
|
|
37
|
+
io.out(MIGRATE_USAGE);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
const target = chooseTarget(values);
|
|
41
|
+
const database = resolveDatabase(io.cwd, values.database);
|
|
42
|
+
const where = describeDatabase(target, database);
|
|
43
|
+
const listed = await io.wrangler([
|
|
44
|
+
"d1",
|
|
45
|
+
"migrations",
|
|
46
|
+
"list",
|
|
47
|
+
database,
|
|
48
|
+
`--${target}`
|
|
49
|
+
]);
|
|
50
|
+
if (listed.code !== 0) throw new CliError(`wrangler could not list the migrations: ${failureReason(listed)}`);
|
|
51
|
+
const pending = parsePendingMigrations(listed.stdout);
|
|
52
|
+
if (pending.length === 0) {
|
|
53
|
+
io.out(`No pending migrations on ${where}.`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
const count = `${pending.length} ${pending.length === 1 ? "migration" : "migrations"}`;
|
|
57
|
+
if (values.check === true) {
|
|
58
|
+
io.out([`${count} pending on ${where}:`, ...pending.map((name) => ` ${name}`)].join("\n"));
|
|
59
|
+
throw new CliError(`${count} pending. Run "hollware migrate --${target}" to apply them.`);
|
|
60
|
+
}
|
|
61
|
+
const backup = target === "remote" ? await takeBackup(io, database) : void 0;
|
|
62
|
+
const applied = await io.wrangler([
|
|
63
|
+
"d1",
|
|
64
|
+
"migrations",
|
|
65
|
+
"apply",
|
|
66
|
+
database,
|
|
67
|
+
`--${target}`
|
|
68
|
+
]);
|
|
69
|
+
if (applied.code !== 0) {
|
|
70
|
+
const saved = backup === void 0 ? "" : ` The backup taken before is in ${backup}.`;
|
|
71
|
+
throw new CliError(`wrangler stopped while applying the migrations: ${failureReason(applied)}. The migrations before the failing one stay applied; "hollware migrate --check" shows what is left.${saved}`);
|
|
72
|
+
}
|
|
73
|
+
io.out([
|
|
74
|
+
`Applied ${count} to ${where}:`,
|
|
75
|
+
...pending.map((name) => ` ${name}`),
|
|
76
|
+
...backup === void 0 ? [] : [`Backup taken before: ${backup}`]
|
|
77
|
+
].join("\n"));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The migrations that `wrangler d1 migrations list` says are pending. It only knows two
|
|
82
|
+
* answers, "No migrations to apply!" and a table headed "Migrations to be applied:"; anything
|
|
83
|
+
* else is an error, so that a wrangler that words it differently never reads as "up to date".
|
|
84
|
+
*/
|
|
85
|
+
function parsePendingMigrations(stdout) {
|
|
86
|
+
const text = stripAnsi(stdout);
|
|
87
|
+
if (text.includes("No migrations to apply!")) return [];
|
|
88
|
+
const marker = text.indexOf("Migrations to be applied:");
|
|
89
|
+
if (marker !== -1) {
|
|
90
|
+
const names = text.slice(marker).split("\n").map((line) => /^[│|]\s*(.+?)\s*[│|]$/.exec(line.trim())?.[1]).filter((cell) => cell !== void 0).slice(1);
|
|
91
|
+
if (names.length > 0) return names;
|
|
92
|
+
}
|
|
93
|
+
throw new CliError("could not read the answer of \"wrangler d1 migrations list\": it neither says that there is nothing to apply nor lists the pending migrations. Is the wrangler of this project a version hollware does not know?");
|
|
94
|
+
}
|
|
95
|
+
async function takeBackup(io, database) {
|
|
96
|
+
const where = describeDatabase("remote", database);
|
|
97
|
+
const file = join(io.cwd, BACKUP_DIRECTORY, `${database}-${timestamp(io.now())}.sql`);
|
|
98
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
99
|
+
io.out(`Backing up ${where} to ${file} ...`);
|
|
100
|
+
const exported = await io.wrangler([
|
|
101
|
+
"d1",
|
|
102
|
+
"export",
|
|
103
|
+
database,
|
|
104
|
+
"--remote",
|
|
105
|
+
"--output",
|
|
106
|
+
file,
|
|
107
|
+
"--skip-confirmation"
|
|
108
|
+
]);
|
|
109
|
+
if (exported.code !== 0) throw new CliError(`the backup of ${where} failed, so no migration was applied: ${failureReason(exported)}`);
|
|
110
|
+
if (!existsSync(file) || statSync(file).size === 0) throw new CliError(`wrangler reported success but wrote no backup to ${file}, so no migration was applied. Check "wrangler d1 export ${database} --remote --output <file>" by hand.`);
|
|
111
|
+
return file;
|
|
112
|
+
}
|
|
113
|
+
/** `20261012T120000Z`: a moment, in UTC, that is safe in a file name. */
|
|
114
|
+
function timestamp(date) {
|
|
115
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
export { migrate };
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { i as UsageError, n as table, r as CliError, t as printable } from "./format-BpRYEsGB.mjs";
|
|
2
|
+
import { t as parseFlags } from "./args-CljWh-kZ.mjs";
|
|
3
|
+
import { _ as sitesAndOwnerQuery, c as listTokens, d as parseLabel, g as revokeToken, h as parseTokenId, i as resolveDatabase, m as parseSiteKey, p as parseScopes, r as query, s as insertToken, t as chooseTarget, u as parseExpiresInDays, v as sitesQuery } from "./d1-Bd-sdoDd.mjs";
|
|
4
|
+
import { n as newId, r as newPersonalToken, t as inDays } from "./entropy-CIYbTHkj.mjs";
|
|
5
|
+
import { PERSONAL_TOKEN_TTL_DAYS, hashPersonalToken } from "@hollware/core";
|
|
6
|
+
//#region src/commands/token.ts
|
|
7
|
+
const WHERE_FLAGS = [
|
|
8
|
+
" --site <key> The site (default: the only site of the database)",
|
|
9
|
+
" --local Use the local database (the default)",
|
|
10
|
+
" --remote Use the database in Cloudflare",
|
|
11
|
+
" --database <name> The D1 database, by name or binding (default: the binding \"DB\" of wrangler.jsonc)",
|
|
12
|
+
" --help Show this help"
|
|
13
|
+
];
|
|
14
|
+
const TOKEN_USAGE = [
|
|
15
|
+
"Usage: hollware token <command> [options]",
|
|
16
|
+
"",
|
|
17
|
+
"Commands:",
|
|
18
|
+
" create Create a personal token and print it once",
|
|
19
|
+
" list List the tokens of a site, never their hashes",
|
|
20
|
+
" revoke Revoke a token",
|
|
21
|
+
"",
|
|
22
|
+
"Run \"hollware token <command> --help\" for the options of a command."
|
|
23
|
+
].join("\n");
|
|
24
|
+
const TOKEN_CREATE_USAGE = [
|
|
25
|
+
"Usage: hollware token create --label <text> [--scopes <list>] [--expires-in-days <n>]",
|
|
26
|
+
" [--site <key>] [--local | --remote] [--database <name>]",
|
|
27
|
+
"",
|
|
28
|
+
"Creates a personal token for the site's owner and prints it once; Hollware keeps only its hash.",
|
|
29
|
+
"",
|
|
30
|
+
"Options:",
|
|
31
|
+
" --label <text> What the token is for, 1 to 64 characters (required)",
|
|
32
|
+
" --scopes <list> Comma-separated: content:read, content:write, content:publish, schema:admin",
|
|
33
|
+
" (default: content:read,content:write)",
|
|
34
|
+
` --expires-in-days <n> Days until it expires, 1 to 365 (default: ${PERSONAL_TOKEN_TTL_DAYS})`,
|
|
35
|
+
...WHERE_FLAGS
|
|
36
|
+
].join("\n");
|
|
37
|
+
const TOKEN_LIST_USAGE = [
|
|
38
|
+
"Usage: hollware token list [--site <key>] [--local | --remote] [--database <name>]",
|
|
39
|
+
"",
|
|
40
|
+
"Lists the tokens of a site: id, label, status, expiry, last use and scopes. Never the hashes.",
|
|
41
|
+
"",
|
|
42
|
+
"Options:",
|
|
43
|
+
...WHERE_FLAGS
|
|
44
|
+
].join("\n");
|
|
45
|
+
const TOKEN_REVOKE_USAGE = [
|
|
46
|
+
"Usage: hollware token revoke <id> [--site <key>] [--local | --remote] [--database <name>]",
|
|
47
|
+
"",
|
|
48
|
+
"Revokes a token: the server refuses it from now on. \"hollware token list\" shows the ids.",
|
|
49
|
+
"",
|
|
50
|
+
"Options:",
|
|
51
|
+
...WHERE_FLAGS
|
|
52
|
+
].join("\n");
|
|
53
|
+
/** The scopes of a token that `--scopes` does not name: to read and to write drafts, not to publish. */
|
|
54
|
+
const DEFAULT_SCOPES = ["content:read", "content:write"];
|
|
55
|
+
async function token(argv, io) {
|
|
56
|
+
const [subcommand, ...rest] = argv;
|
|
57
|
+
switch (subcommand) {
|
|
58
|
+
case void 0:
|
|
59
|
+
case "--help":
|
|
60
|
+
io.out(TOKEN_USAGE);
|
|
61
|
+
return 0;
|
|
62
|
+
case "create": return createToken(rest, io);
|
|
63
|
+
case "list": return listTokenRows(rest, io);
|
|
64
|
+
case "revoke": return revokeTokenById(rest, io);
|
|
65
|
+
default: throw new UsageError(`unknown command "${printable(subcommand)}": use create, list or revoke.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const WHERE_OPTIONS = {
|
|
69
|
+
site: { type: "string" },
|
|
70
|
+
local: { type: "boolean" },
|
|
71
|
+
remote: { type: "boolean" },
|
|
72
|
+
database: { type: "string" },
|
|
73
|
+
help: { type: "boolean" }
|
|
74
|
+
};
|
|
75
|
+
async function createToken(argv, io) {
|
|
76
|
+
const { values } = parseFlags({
|
|
77
|
+
args: [...argv],
|
|
78
|
+
allowPositionals: false,
|
|
79
|
+
options: {
|
|
80
|
+
...WHERE_OPTIONS,
|
|
81
|
+
label: { type: "string" },
|
|
82
|
+
scopes: { type: "string" },
|
|
83
|
+
"expires-in-days": { type: "string" }
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
if (values.help === true) {
|
|
87
|
+
io.out(TOKEN_CREATE_USAGE);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
if (values.label === void 0) throw new UsageError("--label is required: say what the token is for, such as \"ci\" or \"claude-code\".");
|
|
91
|
+
const label = parseLabel(values.label);
|
|
92
|
+
const siteFlag = values.site === void 0 ? void 0 : parseSiteKey(values.site);
|
|
93
|
+
const scopes = values.scopes === void 0 ? DEFAULT_SCOPES : parseScopes(values.scopes);
|
|
94
|
+
const days = values["expires-in-days"] === void 0 ? PERSONAL_TOKEN_TTL_DAYS : parseExpiresInDays(values["expires-in-days"]);
|
|
95
|
+
const target = chooseTarget(values);
|
|
96
|
+
const database = resolveDatabase(io.cwd, values.database);
|
|
97
|
+
const [siteRows = [], ownerRows = []] = await query(io.wrangler, database, target, sitesAndOwnerQuery());
|
|
98
|
+
const site = chooseSite(siteRows, siteFlag);
|
|
99
|
+
const ownerId = firstId(ownerRows);
|
|
100
|
+
if (ownerId === void 0) throw new CliError("the database has no owner yet. Run \"hollware init --site <key>\" first.");
|
|
101
|
+
const now = io.now();
|
|
102
|
+
const id = newId(io, "token");
|
|
103
|
+
const secret = newPersonalToken(io);
|
|
104
|
+
const expiresAt = inDays(now, days);
|
|
105
|
+
await query(io.wrangler, database, target, insertToken({
|
|
106
|
+
id,
|
|
107
|
+
site,
|
|
108
|
+
userId: ownerId,
|
|
109
|
+
label,
|
|
110
|
+
hash: await hashPersonalToken(secret),
|
|
111
|
+
scopes,
|
|
112
|
+
expiresAt,
|
|
113
|
+
now: now.toISOString()
|
|
114
|
+
}));
|
|
115
|
+
io.out([
|
|
116
|
+
`Created token ${id} for the site "${site}".`,
|
|
117
|
+
"",
|
|
118
|
+
`Label ${label}`,
|
|
119
|
+
`Scopes ${scopes.join(" ")}`,
|
|
120
|
+
`Expires ${expiresAt} (in ${days} ${days === 1 ? "day" : "days"})`,
|
|
121
|
+
"",
|
|
122
|
+
"The token. It is shown only once: Hollware keeps just its hash.",
|
|
123
|
+
"",
|
|
124
|
+
secret
|
|
125
|
+
].join("\n"));
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
async function listTokenRows(argv, io) {
|
|
129
|
+
const { values } = parseFlags({
|
|
130
|
+
args: [...argv],
|
|
131
|
+
allowPositionals: false,
|
|
132
|
+
options: WHERE_OPTIONS
|
|
133
|
+
});
|
|
134
|
+
if (values.help === true) {
|
|
135
|
+
io.out(TOKEN_LIST_USAGE);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
const siteFlag = values.site === void 0 ? void 0 : parseSiteKey(values.site);
|
|
139
|
+
const target = chooseTarget(values);
|
|
140
|
+
const database = resolveDatabase(io.cwd, values.database);
|
|
141
|
+
const [siteRows = []] = await query(io.wrangler, database, target, sitesQuery());
|
|
142
|
+
const site = chooseSite(siteRows, siteFlag);
|
|
143
|
+
const [rows = []] = await query(io.wrangler, database, target, listTokens(site));
|
|
144
|
+
if (rows.length === 0) {
|
|
145
|
+
io.out(`The site "${site}" has no tokens.`);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
const now = io.now();
|
|
149
|
+
io.out(table([
|
|
150
|
+
"ID",
|
|
151
|
+
"LABEL",
|
|
152
|
+
"STATUS",
|
|
153
|
+
"EXPIRES",
|
|
154
|
+
"LAST USED",
|
|
155
|
+
"SCOPES"
|
|
156
|
+
], rows.map((row) => [
|
|
157
|
+
text(row.id),
|
|
158
|
+
text(row.label),
|
|
159
|
+
statusOf(row, now),
|
|
160
|
+
text(row.expires_at, "never"),
|
|
161
|
+
text(row.last_used_at, "never"),
|
|
162
|
+
scopesOf(row.scopes)
|
|
163
|
+
])));
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
async function revokeTokenById(argv, io) {
|
|
167
|
+
const { values, positionals } = parseFlags({
|
|
168
|
+
args: [...argv],
|
|
169
|
+
allowPositionals: true,
|
|
170
|
+
options: WHERE_OPTIONS
|
|
171
|
+
});
|
|
172
|
+
if (values.help === true) {
|
|
173
|
+
io.out(TOKEN_REVOKE_USAGE);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
const [rawId, ...extra] = positionals;
|
|
177
|
+
if (rawId === void 0 || extra.length > 0) throw new UsageError("revoke takes the id of one token: hollware token revoke <id>.");
|
|
178
|
+
const id = parseTokenId(rawId);
|
|
179
|
+
const site = values.site === void 0 ? void 0 : parseSiteKey(values.site);
|
|
180
|
+
const target = chooseTarget(values);
|
|
181
|
+
const database = resolveDatabase(io.cwd, values.database);
|
|
182
|
+
const [revoked = [], current = []] = await query(io.wrangler, database, target, revokeToken(id, site, io.now().toISOString()));
|
|
183
|
+
if (revoked.length > 0) {
|
|
184
|
+
io.out(`Revoked token ${id}.`);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
const [row] = current;
|
|
188
|
+
if (row === void 0) {
|
|
189
|
+
const inSite = site === void 0 ? "" : ` in the site "${site}"`;
|
|
190
|
+
throw new CliError(`there is no token ${id}${inSite}. "hollware token list" shows the tokens.`);
|
|
191
|
+
}
|
|
192
|
+
io.out(`Token ${id} was already revoked at ${text(row.revoked_at)}. Nothing changed.`);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
/** The site a command acts on: the one `--site` names (already checked), or the only one of the database. */
|
|
196
|
+
function chooseSite(rows, site) {
|
|
197
|
+
const sites = rows.map((row) => row.id).filter((id) => typeof id === "string");
|
|
198
|
+
if (site !== void 0) {
|
|
199
|
+
if (!sites.includes(site)) throw new CliError(`the database has no site "${site}"${sites.length === 0 ? "" : ` (it has ${sites.join(", ")})`}. Run "hollware init --site ${site}" to create it.`);
|
|
200
|
+
return site;
|
|
201
|
+
}
|
|
202
|
+
const [only, ...others] = sites;
|
|
203
|
+
if (only === void 0) throw new CliError("the database has no site yet. Run \"hollware init --site <key>\" first.");
|
|
204
|
+
if (others.length > 0) throw new CliError(`the database has several sites (${sites.join(", ")}): choose one with --site <key>.`);
|
|
205
|
+
return only;
|
|
206
|
+
}
|
|
207
|
+
function firstId(rows) {
|
|
208
|
+
const [row] = rows;
|
|
209
|
+
return typeof row?.id === "string" ? row.id : void 0;
|
|
210
|
+
}
|
|
211
|
+
function text(value, otherwise = "") {
|
|
212
|
+
return typeof value === "string" ? printable(value) : otherwise;
|
|
213
|
+
}
|
|
214
|
+
function statusOf(row, now) {
|
|
215
|
+
if (typeof row.revoked_at === "string") return "revoked";
|
|
216
|
+
if (typeof row.expires_at === "string" && Date.parse(row.expires_at) <= now.getTime()) return "expired";
|
|
217
|
+
return "active";
|
|
218
|
+
}
|
|
219
|
+
function scopesOf(value) {
|
|
220
|
+
if (typeof value !== "string") return "";
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(value);
|
|
223
|
+
if (Array.isArray(parsed)) return parsed.filter((scope) => typeof scope === "string").map(printable).join(" ");
|
|
224
|
+
} catch {}
|
|
225
|
+
return printable(value);
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
export { token };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
//#region src/version.ts
|
|
3
|
+
function readVersion() {
|
|
4
|
+
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
5
|
+
if (typeof manifest.version !== "string") throw new Error("package.json has no version");
|
|
6
|
+
return manifest.version;
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
9
|
+
export { readVersion as t };
|