@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 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`), and a pointer to
7
- * `create-takuhon` for scaffolding. The `sync` subcommand lands in a subsequent
8
- * release.
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 readFileSync8, realpathSync } from "fs";
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/validate-command.ts
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 USAGE7 = `Usage: takuhon validate [path]
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: USAGE7, stderr: "" };
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 ?? DEFAULT_PATH7;
1801
+ const target = pathArg ?? DEFAULT_PATH8;
1541
1802
  let raw;
1542
1803
  try {
1543
- raw = readFileSync7(target, "utf8");
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 = validate6(data);
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(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
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\`.