@takuhon/cli 0.8.2 → 0.9.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/index.d.ts +3 -3
- package/dist/index.js +276 -13
- package/dist/index.js.map +1 -1
- package/dist/init.js +19 -30
- package/dist/init.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* `@takuhon/cli` entry point — the `takuhon` command.
|
|
4
4
|
*
|
|
5
5
|
* Exposes `--version` / `--help`, the local profile commands (`validate`,
|
|
6
|
-
* `migrate`, `restore`, `export`, `import`, `build`, `dev`),
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* `migrate`, `restore`, `export`, `import`, `build`, `dev`), `sync` (push a
|
|
7
|
+
* local profile to a deployment), and a pointer to `create-takuhon` for
|
|
8
|
+
* scaffolding.
|
|
9
9
|
*
|
|
10
10
|
* `main` is pure (returns an exit code, never calls `process.exit`); the only
|
|
11
11
|
* place that exits the process is {@link run}, invoked either when this module
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync9, realpathSync } from "fs";
|
|
5
5
|
import { stdin, stdout } from "process";
|
|
6
6
|
import { createInterface } from "readline/promises";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
@@ -1512,11 +1512,272 @@ function isNotFound2(error) {
|
|
|
1512
1512
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
1513
1513
|
}
|
|
1514
1514
|
|
|
1515
|
-
// src/
|
|
1515
|
+
// src/sync-command.ts
|
|
1516
1516
|
import { readFileSync as readFileSync7 } from "fs";
|
|
1517
1517
|
import { validate as validate6 } from "@takuhon/core";
|
|
1518
1518
|
var DEFAULT_PATH7 = "takuhon.json";
|
|
1519
|
-
var
|
|
1519
|
+
var ADMIN_PROFILE_PATH = "/api/admin/profile";
|
|
1520
|
+
var TOKEN_ENV = "TAKUHON_ADMIN_TOKEN";
|
|
1521
|
+
var USAGE7 = `Usage: takuhon sync [path] --url <base-url> [--if-match <etag>] [--dry-run]
|
|
1522
|
+
|
|
1523
|
+
Push a local takuhon.json to a deployed takuhon instance by calling its admin
|
|
1524
|
+
write endpoint (PUT <base-url>/api/admin/profile). With no path, syncs
|
|
1525
|
+
./takuhon.json in the current working directory.
|
|
1526
|
+
|
|
1527
|
+
The local file is the source of truth: by default the push is unconditional
|
|
1528
|
+
(a mirror). Pass --if-match <etag> to opt into optimistic locking (the server
|
|
1529
|
+
returns 409 if the stored version no longer matches). The document is sent
|
|
1530
|
+
as-is after a local schema check; run \`takuhon migrate\` first if it is an
|
|
1531
|
+
older schema version.
|
|
1532
|
+
|
|
1533
|
+
The admin bearer token is read from the ${TOKEN_ENV} environment variable, e.g.:
|
|
1534
|
+
${TOKEN_ENV}=... takuhon sync --url https://me.example
|
|
1535
|
+
|
|
1536
|
+
Options:
|
|
1537
|
+
--url <base-url> Required. Absolute http(s) origin of the deployment.
|
|
1538
|
+
--if-match <etag> Send If-Match for optimistic locking (opt-in).
|
|
1539
|
+
--dry-run Validate locally and report what would be sent; no request.
|
|
1540
|
+
|
|
1541
|
+
Exit codes: 0 = synced (or dry-run ok), 1 = local invalid / remote refused
|
|
1542
|
+
(422 / 409), 2 = bad arguments / file missing / unreadable / not JSON / token
|
|
1543
|
+
unset / auth failure / network error / other non-success response.
|
|
1544
|
+
`;
|
|
1545
|
+
async function runSync(args = [], deps = {}) {
|
|
1546
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
1547
|
+
return { code: 0, stdout: USAGE7, stderr: "" };
|
|
1548
|
+
}
|
|
1549
|
+
const parsed = parseArgs7(args);
|
|
1550
|
+
if ("error" in parsed) {
|
|
1551
|
+
return {
|
|
1552
|
+
code: 2,
|
|
1553
|
+
stdout: "",
|
|
1554
|
+
stderr: `${parsed.error}
|
|
1555
|
+
Run \`takuhon sync --help\` for usage.
|
|
1556
|
+
`
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
return syncProfile(parsed, deps);
|
|
1560
|
+
}
|
|
1561
|
+
function parseArgs7(args) {
|
|
1562
|
+
let path;
|
|
1563
|
+
let url;
|
|
1564
|
+
let ifMatch;
|
|
1565
|
+
let dryRun = false;
|
|
1566
|
+
for (let i = 0; i < args.length; i++) {
|
|
1567
|
+
const arg = args[i];
|
|
1568
|
+
if (arg === "--dry-run") {
|
|
1569
|
+
dryRun = true;
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
if (arg === "--url" || arg === "--if-match") {
|
|
1573
|
+
const value = args[i + 1];
|
|
1574
|
+
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
1575
|
+
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
1576
|
+
}
|
|
1577
|
+
if (arg === "--url") url = value;
|
|
1578
|
+
else ifMatch = value;
|
|
1579
|
+
i++;
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
if (arg.startsWith("--url=")) {
|
|
1583
|
+
const value = arg.slice("--url=".length);
|
|
1584
|
+
if (value === "") return { error: "takuhon: `--url` requires a value." };
|
|
1585
|
+
url = value;
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
if (arg.startsWith("--if-match=")) {
|
|
1589
|
+
const value = arg.slice("--if-match=".length);
|
|
1590
|
+
if (value === "") return { error: "takuhon: `--if-match` requires a value." };
|
|
1591
|
+
ifMatch = value;
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
if (arg.startsWith("-")) {
|
|
1595
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`sync\`.` };
|
|
1596
|
+
}
|
|
1597
|
+
if (path !== void 0) {
|
|
1598
|
+
return { error: "takuhon: `sync` takes at most one path argument." };
|
|
1599
|
+
}
|
|
1600
|
+
path = arg;
|
|
1601
|
+
}
|
|
1602
|
+
if (url === void 0) {
|
|
1603
|
+
return { error: "takuhon: `sync` requires `--url <base-url>`." };
|
|
1604
|
+
}
|
|
1605
|
+
const base = parseOrigin(url);
|
|
1606
|
+
if ("error" in base) return base;
|
|
1607
|
+
return { path: path ?? DEFAULT_PATH7, url: base.origin, ifMatch, dryRun };
|
|
1608
|
+
}
|
|
1609
|
+
function parseOrigin(value) {
|
|
1610
|
+
let parsed;
|
|
1611
|
+
try {
|
|
1612
|
+
parsed = new URL(value);
|
|
1613
|
+
} catch {
|
|
1614
|
+
return { error: "takuhon: `--url` must be an absolute http(s) URL." };
|
|
1615
|
+
}
|
|
1616
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1617
|
+
return { error: "takuhon: `--url` must be an absolute http(s) URL." };
|
|
1618
|
+
}
|
|
1619
|
+
if (parsed.username !== "" || parsed.password !== "" || parsed.search !== "" || parsed.hash !== "" || parsed.pathname !== "/" && parsed.pathname !== "") {
|
|
1620
|
+
return {
|
|
1621
|
+
error: "takuhon: `--url` must be the deployment's origin (e.g. https://me.example), with no path, query, or credentials."
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
return { origin: parsed.origin };
|
|
1625
|
+
}
|
|
1626
|
+
function redact(text, secret) {
|
|
1627
|
+
return secret === "" ? text : text.split(secret).join("***");
|
|
1628
|
+
}
|
|
1629
|
+
function unquoteETag(raw) {
|
|
1630
|
+
const trimmed = raw.trim();
|
|
1631
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
|
1632
|
+
return trimmed.slice(1, -1);
|
|
1633
|
+
}
|
|
1634
|
+
return trimmed;
|
|
1635
|
+
}
|
|
1636
|
+
async function syncProfile(parsed, deps) {
|
|
1637
|
+
const { path, url, ifMatch, dryRun } = parsed;
|
|
1638
|
+
let raw;
|
|
1639
|
+
try {
|
|
1640
|
+
raw = readFileSync7(path, "utf8");
|
|
1641
|
+
} catch {
|
|
1642
|
+
return { code: 2, stdout: "", stderr: `takuhon: cannot read '${path}'.
|
|
1643
|
+
` };
|
|
1644
|
+
}
|
|
1645
|
+
let data;
|
|
1646
|
+
try {
|
|
1647
|
+
data = JSON.parse(raw);
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1650
|
+
return { code: 2, stdout: "", stderr: `takuhon: '${path}' is not valid JSON: ${detail}
|
|
1651
|
+
` };
|
|
1652
|
+
}
|
|
1653
|
+
const result = validate6(data);
|
|
1654
|
+
if (!result.ok) {
|
|
1655
|
+
const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
1656
|
+
return {
|
|
1657
|
+
code: 1,
|
|
1658
|
+
stdout: "",
|
|
1659
|
+
stderr: `takuhon: '${path}' is not a valid takuhon profile; refusing to sync:
|
|
1660
|
+
${lines.join("\n")}
|
|
1661
|
+
`
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
const endpoint = `${url}${ADMIN_PROFILE_PATH}`;
|
|
1665
|
+
const body = `${JSON.stringify(result.data)}
|
|
1666
|
+
`;
|
|
1667
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
1668
|
+
if (dryRun) {
|
|
1669
|
+
const lock = ifMatch !== void 0 ? `, If-Match "${unquoteETag(ifMatch)}"` : "";
|
|
1670
|
+
return {
|
|
1671
|
+
code: 0,
|
|
1672
|
+
stdout: `would sync ${path} -> ${endpoint} (${String(bytes)} bytes${lock})
|
|
1673
|
+
--dry-run: nothing sent.
|
|
1674
|
+
`,
|
|
1675
|
+
stderr: ""
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
const getToken = deps.getToken ?? (() => process.env[TOKEN_ENV]);
|
|
1679
|
+
const token = getToken();
|
|
1680
|
+
if (token === void 0 || token === "") {
|
|
1681
|
+
return {
|
|
1682
|
+
code: 2,
|
|
1683
|
+
stdout: "",
|
|
1684
|
+
stderr: `takuhon: ${TOKEN_ENV} is not set; sync needs the deployment's admin token.
|
|
1685
|
+
Set it for this command only, e.g.:
|
|
1686
|
+
${TOKEN_ENV}=... takuhon sync --url ${url}
|
|
1687
|
+
`
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
const headers = {
|
|
1691
|
+
"content-type": "application/json",
|
|
1692
|
+
authorization: `Bearer ${token}`
|
|
1693
|
+
};
|
|
1694
|
+
if (ifMatch !== void 0) {
|
|
1695
|
+
headers["if-match"] = `"${unquoteETag(ifMatch)}"`;
|
|
1696
|
+
}
|
|
1697
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
1698
|
+
let res;
|
|
1699
|
+
try {
|
|
1700
|
+
res = await fetchImpl(endpoint, { method: "PUT", headers, body });
|
|
1701
|
+
} catch (error) {
|
|
1702
|
+
const detail = redact(error instanceof Error ? error.message : String(error), token);
|
|
1703
|
+
return { code: 2, stdout: "", stderr: `takuhon: could not reach ${endpoint}: ${detail}
|
|
1704
|
+
` };
|
|
1705
|
+
}
|
|
1706
|
+
return interpretResponse(res, { path, url, endpoint });
|
|
1707
|
+
}
|
|
1708
|
+
async function interpretResponse(res, target) {
|
|
1709
|
+
const { path, url, endpoint } = target;
|
|
1710
|
+
if (res.ok) {
|
|
1711
|
+
const version = await readVersion(res);
|
|
1712
|
+
if (version === void 0) {
|
|
1713
|
+
return {
|
|
1714
|
+
code: 2,
|
|
1715
|
+
stdout: "",
|
|
1716
|
+
stderr: `takuhon: unexpected response from ${endpoint} (HTTP ${String(res.status)} but no meta.version); is --url a takuhon deployment?
|
|
1717
|
+
`
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
return { code: 0, stdout: `synced ${path} -> ${url} (version ${version})
|
|
1721
|
+
`, stderr: "" };
|
|
1722
|
+
}
|
|
1723
|
+
const problem = await readProblem(res);
|
|
1724
|
+
const status = res.status;
|
|
1725
|
+
if (status === 409) {
|
|
1726
|
+
const current = problem?.currentVersion;
|
|
1727
|
+
const hint = current !== void 0 ? `remote is at version ${current}. Re-sync against it, or drop --if-match to overwrite.` : "the stored version no longer matches --if-match. Re-sync, or drop --if-match to overwrite.";
|
|
1728
|
+
return { code: 1, stdout: "", stderr: `takuhon: sync conflict (409): ${hint}
|
|
1729
|
+
` };
|
|
1730
|
+
}
|
|
1731
|
+
if (status === 422) {
|
|
1732
|
+
const errors = Array.isArray(problem?.errors) ? problem.errors : [];
|
|
1733
|
+
const lines = errors.map((e) => ` ${e.path}: ${e.message}`);
|
|
1734
|
+
const detail2 = lines.length > 0 ? `:
|
|
1735
|
+
${lines.join("\n")}` : ".";
|
|
1736
|
+
return {
|
|
1737
|
+
code: 1,
|
|
1738
|
+
stdout: "",
|
|
1739
|
+
stderr: `takuhon: the deployment rejected the profile (422)${detail2}
|
|
1740
|
+
`
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
if (status === 401 || status === 403) {
|
|
1744
|
+
const reason = status === 401 ? `unauthorized (401); check ${TOKEN_ENV}` : "forbidden (403); the request origin may not be allowed";
|
|
1745
|
+
return { code: 2, stdout: "", stderr: `takuhon: ${reason}.
|
|
1746
|
+
` };
|
|
1747
|
+
}
|
|
1748
|
+
const detail = problem?.detail ?? res.statusText ?? "";
|
|
1749
|
+
const tail = detail ? `: ${detail}` : ".";
|
|
1750
|
+
return { code: 2, stdout: "", stderr: `takuhon: sync failed (${String(status)})${tail}
|
|
1751
|
+
` };
|
|
1752
|
+
}
|
|
1753
|
+
async function readVersion(res) {
|
|
1754
|
+
try {
|
|
1755
|
+
const parsed = await res.json();
|
|
1756
|
+
const version = parsed.meta?.version;
|
|
1757
|
+
return typeof version === "string" ? version : void 0;
|
|
1758
|
+
} catch {
|
|
1759
|
+
return void 0;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
async function readProblem(res) {
|
|
1763
|
+
let text;
|
|
1764
|
+
try {
|
|
1765
|
+
text = await res.text();
|
|
1766
|
+
} catch {
|
|
1767
|
+
return void 0;
|
|
1768
|
+
}
|
|
1769
|
+
try {
|
|
1770
|
+
return JSON.parse(text);
|
|
1771
|
+
} catch {
|
|
1772
|
+
return void 0;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// src/validate-command.ts
|
|
1777
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
1778
|
+
import { validate as validate7 } from "@takuhon/core";
|
|
1779
|
+
var DEFAULT_PATH8 = "takuhon.json";
|
|
1780
|
+
var USAGE8 = `Usage: takuhon validate [path]
|
|
1520
1781
|
|
|
1521
1782
|
Validate a takuhon.json against the takuhon schema. With no path, validates
|
|
1522
1783
|
./takuhon.json in the current working directory.
|
|
@@ -1525,7 +1786,7 @@ Exit codes: 0 = valid, 1 = invalid, 2 = file missing / unreadable / not JSON.
|
|
|
1525
1786
|
`;
|
|
1526
1787
|
function runValidate(args = []) {
|
|
1527
1788
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1528
|
-
return { code: 0, stdout:
|
|
1789
|
+
return { code: 0, stdout: USAGE8, stderr: "" };
|
|
1529
1790
|
}
|
|
1530
1791
|
if (args.length > 1) {
|
|
1531
1792
|
return {
|
|
@@ -1537,10 +1798,10 @@ function runValidate(args = []) {
|
|
|
1537
1798
|
return validateFile(args[0]);
|
|
1538
1799
|
}
|
|
1539
1800
|
function validateFile(pathArg) {
|
|
1540
|
-
const target = pathArg ??
|
|
1801
|
+
const target = pathArg ?? DEFAULT_PATH8;
|
|
1541
1802
|
let raw;
|
|
1542
1803
|
try {
|
|
1543
|
-
raw =
|
|
1804
|
+
raw = readFileSync8(target, "utf8");
|
|
1544
1805
|
} catch {
|
|
1545
1806
|
return {
|
|
1546
1807
|
code: 2,
|
|
@@ -1561,7 +1822,7 @@ function validateFile(pathArg) {
|
|
|
1561
1822
|
`
|
|
1562
1823
|
};
|
|
1563
1824
|
}
|
|
1564
|
-
const result =
|
|
1825
|
+
const result = validate7(data);
|
|
1565
1826
|
if (result.ok) {
|
|
1566
1827
|
return {
|
|
1567
1828
|
code: 0,
|
|
@@ -1582,7 +1843,7 @@ ${lines.join("\n")}
|
|
|
1582
1843
|
}
|
|
1583
1844
|
|
|
1584
1845
|
// src/index.ts
|
|
1585
|
-
var pkg = JSON.parse(
|
|
1846
|
+
var pkg = JSON.parse(readFileSync9(new URL("../package.json", import.meta.url), "utf8"));
|
|
1586
1847
|
var VERSION = pkg.version;
|
|
1587
1848
|
var HELP = `takuhon ${VERSION}
|
|
1588
1849
|
|
|
@@ -1608,15 +1869,14 @@ Commands:
|
|
|
1608
1869
|
takuhon dev [path] [--port <n>] Serve a takuhon.json as a local static preview,
|
|
1609
1870
|
re-rendered on each request (default port: 4321).
|
|
1610
1871
|
--base-url <url> adds canonical/hreflang links.
|
|
1872
|
+
takuhon sync [path] --url <url> Push a takuhon.json to a deployment's admin API
|
|
1873
|
+
(PUT <url>/api/admin/profile). Reads the admin token
|
|
1874
|
+
from TAKUHON_ADMIN_TOKEN. --if-match <etag> opts into
|
|
1875
|
+
optimistic locking; --dry-run previews without sending.
|
|
1611
1876
|
|
|
1612
1877
|
Scaffolding a new profile project:
|
|
1613
1878
|
npx create-takuhon my-profile
|
|
1614
1879
|
npx create-takuhon my-profile --license CC-BY-4.0
|
|
1615
|
-
|
|
1616
|
-
The sync subcommand is planned for a future release.
|
|
1617
|
-
Track progress at:
|
|
1618
|
-
|
|
1619
|
-
https://github.com/takuhon-dev/takuhon
|
|
1620
1880
|
`;
|
|
1621
1881
|
async function main(argv) {
|
|
1622
1882
|
const first = argv[0];
|
|
@@ -1651,6 +1911,9 @@ async function main(argv) {
|
|
|
1651
1911
|
const confirm = stdin.isTTY ? promptConfirm : void 0;
|
|
1652
1912
|
return emit(await runRestore(argv.slice(1), { confirm }));
|
|
1653
1913
|
}
|
|
1914
|
+
if (first === "sync") {
|
|
1915
|
+
return emit(await runSync(argv.slice(1)));
|
|
1916
|
+
}
|
|
1654
1917
|
process.stderr.write(
|
|
1655
1918
|
`takuhon: unknown command '${first}'
|
|
1656
1919
|
Run \`takuhon --help\` for usage. For scaffolding a new project, use \`create-takuhon\`.
|