@evcraddock/slug-cli 0.8.0 → 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/README.md CHANGED
@@ -75,6 +75,33 @@ The imported body.
75
75
 
76
76
  A relative `banner` path is resolved from the Markdown file, uploaded through the Slugkit media API under `posts/<slug>/banner.<extension>`, and inserted as the first Markdown image in the post body so standard Slugkit article previews render it. The command looks up the post by slug: it creates a missing post and updates an existing one, replacing the imported metadata, body, tags, and publication date. Re-importing a banner uploads the current file again to refresh the deterministic media object key.
77
77
 
78
+ ## Source imports
79
+
80
+ Import link sources and their optional contacts and accounts from YAML with `slug --site <name> sources import <file.yaml>`. The file has one required top-level `sources` array. Each source requires `name` and may include `url`, `description`, `imageUrl`, `faviconUrl`, `contacts`, and `accounts`. Contacts require `name` and may include `url` and `accounts`. Accounts require `label` and `url` and may include `avatarUrl`, `kind`, `protocol`, `default`, and `sortOrder`.
81
+
82
+ ```yaml
83
+ sources:
84
+ - name: Example Blog
85
+ url: https://example.com
86
+ description: Example writing
87
+ accounts:
88
+ - label: Feed
89
+ url: https://example.com/feed.xml
90
+ kind: feed
91
+ protocol: rss
92
+ default: true
93
+ contacts:
94
+ - name: Jane Example
95
+ url: https://example.com/jane
96
+ accounts:
97
+ - label: Mastodon
98
+ url: https://social.example/@jane
99
+ kind: social
100
+ protocol: activitypub
101
+ ```
102
+
103
+ The CLI validates the complete file before sending requests. It creates contacts first, associates their returned IDs when creating each source, and then creates accounts with the returned contact or source owner ID. Sources may omit both `contacts` and `accounts`. An API failure stops the import; because the API does not provide a bulk transaction, records created by earlier successful requests remain.
104
+
78
105
  Use `slug tag list` to list tags and post usage counts through the Slugkit tags API.
79
106
 
80
107
  Example config:
package/dist/commands.js CHANGED
@@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, basename, extname, join, resolve } from "node:path";
4
4
  import AdmZip from "adm-zip";
5
+ import { parse as parseYaml } from "yaml";
5
6
  import { SLUGKIT_API_MAJOR_VERSION, SLUGKIT_API_NAME, compareSlugkitApiVersions, normalizeApiBaseUrl, readSlugkitApiMajorVersion, } from "@evcraddock/slug-core";
6
7
  import { isValidSiteName, readConfig, removeConfigSite, setConfigSiteApiBaseUrl, setConfigSiteApiKey, toDisplayConfig, writeConfig, } from "./config.js";
7
8
  import { CliError, createInvalidUsageError, ExitCode } from "./errors.js";
@@ -36,6 +37,7 @@ Usage:
36
37
  slug [--config <file>] sources list [--json]
37
38
  slug [--config <file>] sources show <id> [--json]
38
39
  slug [--config <file>] sources create --name <name> [--url <url>] [--description <text>] [--image-url <url>] [--favicon-url <url>] [--contact-id <id>]... [--json]
40
+ slug [--config <file>] sources import <file.yaml> [--json]
39
41
  slug [--config <file>] sources edit <id> [--name <name>] [--url <url>] [--description <text>] [--image-url <url>] [--favicon-url <url>] [--contact-id <id>]... [--json]
40
42
  slug [--config <file>] sources delete <id> [--json]
41
43
  slug [--config <file>] accounts list [--owner-type contact|source|site] [--owner-id <id>] [--json]
@@ -1667,12 +1669,14 @@ async function runSourcesCommand(context, args) {
1667
1669
  return runSourcesShowCommand(context, args.slice(1));
1668
1670
  case "create":
1669
1671
  return runSourcesCreateCommand(context, args.slice(1));
1672
+ case "import":
1673
+ return runSourcesImportCommand(context, args.slice(1));
1670
1674
  case "edit":
1671
1675
  return runSourcesEditCommand(context, args.slice(1));
1672
1676
  case "delete":
1673
1677
  return runSourcesDeleteCommand(context, args.slice(1));
1674
1678
  default:
1675
- throw createInvalidUsageError("Usage: slug sources <list|show|create|edit|delete>");
1679
+ throw createInvalidUsageError("Usage: slug sources <list|show|create|import|edit|delete>");
1676
1680
  }
1677
1681
  }
1678
1682
  async function runSourcesListCommand(context, args) {
@@ -1714,6 +1718,250 @@ async function runSourcesCreateCommand(context, args) {
1714
1718
  writeSourceMutationOutput(context.writer, response, parsed.json);
1715
1719
  return { exitCode: ExitCode.Ok };
1716
1720
  }
1721
+ async function runSourcesImportCommand(context, args) {
1722
+ const { filePath, json } = readSourceImportArgs(args);
1723
+ const sources = await readSourceImportFile(filePath);
1724
+ const api = await createConfiguredApiContext(context);
1725
+ const result = {
1726
+ sources: [],
1727
+ totals: { sources: 0, contacts: 0, accounts: 0 },
1728
+ };
1729
+ writeMutationTarget(context.writer, api.apiBaseUrl, json);
1730
+ for (const [sourceIndex, source] of sources.entries()) {
1731
+ result.sources.push(await importSource(api.client, source, sourceIndex, result.totals));
1732
+ }
1733
+ if (json) {
1734
+ writeJson(context.writer, { data: result });
1735
+ }
1736
+ else {
1737
+ writeSourceImportSummary(context.writer, result.totals);
1738
+ }
1739
+ return { exitCode: ExitCode.Ok };
1740
+ }
1741
+ function readSourceImportArgs(args) {
1742
+ const json = args.includes("--json");
1743
+ const positional = args.filter((arg) => arg !== "--json");
1744
+ if (positional.length !== 1 || positional[0] === undefined || positional[0].startsWith("--")) {
1745
+ throw createInvalidUsageError("Usage: slug sources import <file.yaml> [--json]");
1746
+ }
1747
+ return { filePath: positional[0], json };
1748
+ }
1749
+ async function readSourceImportFile(filePath) {
1750
+ let content;
1751
+ try {
1752
+ content = await readFile(filePath, "utf8");
1753
+ }
1754
+ catch {
1755
+ throw createInvalidUsageError(`Cannot read source import file: ${filePath}`);
1756
+ }
1757
+ let document;
1758
+ try {
1759
+ document = parseYaml(content);
1760
+ }
1761
+ catch (error) {
1762
+ const message = error instanceof Error ? error.message : "Unable to parse YAML";
1763
+ throw createInvalidUsageError(`Invalid source import YAML: ${message}`);
1764
+ }
1765
+ return parseSourceImportDocument(document);
1766
+ }
1767
+ function parseSourceImportDocument(value) {
1768
+ const root = readSourceImportRecord(value, "document", ["sources"]);
1769
+ if (!Array.isArray(root.sources) || root.sources.length === 0) {
1770
+ throw createInvalidUsageError("sources must be a non-empty array");
1771
+ }
1772
+ return root.sources.map((source, index) => parseSourceImportSource(source, `sources[${index}]`));
1773
+ }
1774
+ function parseSourceImportSource(value, path) {
1775
+ const record = readSourceImportRecord(value, path, [
1776
+ "name",
1777
+ "url",
1778
+ "description",
1779
+ "imageUrl",
1780
+ "faviconUrl",
1781
+ "contacts",
1782
+ "accounts",
1783
+ ]);
1784
+ return {
1785
+ name: readSourceImportRequiredString(record, "name", path),
1786
+ ...readSourceImportOptionalUrl(record, "url", path, ["http:", "https:"]),
1787
+ ...readSourceImportOptionalString(record, "description", path),
1788
+ ...readSourceImportOptionalUrl(record, "imageUrl", path, ["http:", "https:"]),
1789
+ ...readSourceImportOptionalUrl(record, "faviconUrl", path, ["http:", "https:"]),
1790
+ contacts: readSourceImportArray(record, "contacts", path, parseSourceImportContact),
1791
+ accounts: readSourceImportArray(record, "accounts", path, parseSourceImportAccount),
1792
+ };
1793
+ }
1794
+ function parseSourceImportContact(value, path) {
1795
+ const record = readSourceImportRecord(value, path, ["name", "url", "accounts"]);
1796
+ return {
1797
+ name: readSourceImportRequiredString(record, "name", path),
1798
+ ...readSourceImportOptionalUrl(record, "url", path, ["http:", "https:"]),
1799
+ accounts: readSourceImportArray(record, "accounts", path, parseSourceImportAccount),
1800
+ };
1801
+ }
1802
+ function parseSourceImportAccount(value, path) {
1803
+ const record = readSourceImportRecord(value, path, [
1804
+ "label",
1805
+ "url",
1806
+ "avatarUrl",
1807
+ "kind",
1808
+ "protocol",
1809
+ "default",
1810
+ "sortOrder",
1811
+ ]);
1812
+ const account = {
1813
+ label: readSourceImportRequiredString(record, "label", path),
1814
+ url: readSourceImportRequiredUrl(record, "url", path, ["http:", "https:", "mailto:"]),
1815
+ ...readSourceImportOptionalUrl(record, "avatarUrl", path, ["http:", "https:"]),
1816
+ ...readSourceImportOptionalString(record, "kind", path),
1817
+ ...readSourceImportOptionalString(record, "protocol", path),
1818
+ };
1819
+ if ("default" in record)
1820
+ account.default = readSourceImportBoolean(record, "default", path);
1821
+ if ("sortOrder" in record)
1822
+ account.sortOrder = readSourceImportInteger(record, "sortOrder", path);
1823
+ return account;
1824
+ }
1825
+ function readSourceImportRecord(value, path, allowedFields) {
1826
+ if (!isRecord(value))
1827
+ throw createInvalidUsageError(`${path} must be an object`);
1828
+ for (const key of Object.keys(value)) {
1829
+ if (!allowedFields.includes(key)) {
1830
+ throw createInvalidUsageError(`${path}.${key} is not supported`);
1831
+ }
1832
+ }
1833
+ return value;
1834
+ }
1835
+ function readSourceImportRequiredString(record, key, path) {
1836
+ const value = record[key];
1837
+ if (typeof value !== "string" || value.trim() === "") {
1838
+ throw createInvalidUsageError(`${path}.${key} must be a non-empty string`);
1839
+ }
1840
+ return value;
1841
+ }
1842
+ function readSourceImportOptionalString(record, key, path) {
1843
+ if (!(key in record))
1844
+ return {};
1845
+ if (typeof record[key] !== "string") {
1846
+ throw createInvalidUsageError(`${path}.${key} must be a string`);
1847
+ }
1848
+ return { [key]: record[key] };
1849
+ }
1850
+ function readSourceImportRequiredUrl(record, key, path, protocols) {
1851
+ const value = readSourceImportRequiredString(record, key, path);
1852
+ assertSourceImportUrl(value, `${path}.${key}`, protocols);
1853
+ return value;
1854
+ }
1855
+ function readSourceImportOptionalUrl(record, key, path, protocols) {
1856
+ if (!(key in record))
1857
+ return {};
1858
+ const value = readSourceImportRequiredString(record, key, path);
1859
+ assertSourceImportUrl(value, `${path}.${key}`, protocols);
1860
+ return { [key]: value };
1861
+ }
1862
+ function assertSourceImportUrl(value, path, protocols) {
1863
+ try {
1864
+ const url = new URL(value);
1865
+ if (!protocols.includes(url.protocol))
1866
+ throw new Error("Unsupported protocol");
1867
+ }
1868
+ catch {
1869
+ throw createInvalidUsageError(`${path} must be a valid ${protocols.join(" or ")} URL`);
1870
+ }
1871
+ }
1872
+ function readSourceImportArray(record, key, path, parseItem) {
1873
+ if (!(key in record))
1874
+ return [];
1875
+ const value = record[key];
1876
+ if (!Array.isArray(value))
1877
+ throw createInvalidUsageError(`${path}.${key} must be an array`);
1878
+ return value.map((item, index) => parseItem(item, `${path}.${key}[${index}]`));
1879
+ }
1880
+ function readSourceImportBoolean(record, key, path) {
1881
+ if (typeof record[key] !== "boolean") {
1882
+ throw createInvalidUsageError(`${path}.${key} must be a boolean`);
1883
+ }
1884
+ return record[key];
1885
+ }
1886
+ function readSourceImportInteger(record, key, path) {
1887
+ if (typeof record[key] !== "number" || !Number.isInteger(record[key])) {
1888
+ throw createInvalidUsageError(`${path}.${key} must be an integer`);
1889
+ }
1890
+ return record[key];
1891
+ }
1892
+ async function importSource(client, source, sourceIndex, totals) {
1893
+ const contacts = [];
1894
+ for (const [contactIndex, contact] of source.contacts.entries()) {
1895
+ const response = await requestSourceImport(client, "/contacts", { name: contact.name, ...(contact.url === undefined ? {} : { url: contact.url }) }, `sources[${sourceIndex}].contacts[${contactIndex}]`);
1896
+ contacts.push({ input: contact, document: response.data, accountIds: [] });
1897
+ totals.contacts += 1;
1898
+ }
1899
+ const sourceResponse = await requestSourceImport(client, "/sources", createImportedSourceBody(source, contacts.map((contact) => contact.document.id)), `sources[${sourceIndex}]`);
1900
+ totals.sources += 1;
1901
+ for (const [contactIndex, contact] of contacts.entries()) {
1902
+ contact.accountIds.push(...(await importAccounts(client, contact.input.accounts, "contact", contact.document.id, `sources[${sourceIndex}].contacts[${contactIndex}].accounts`, totals)));
1903
+ }
1904
+ const accountIds = await importAccounts(client, source.accounts, "source", sourceResponse.data.id, `sources[${sourceIndex}].accounts`, totals);
1905
+ return {
1906
+ id: sourceResponse.data.id,
1907
+ name: sourceResponse.data.name,
1908
+ contacts: contacts.map((contact) => ({
1909
+ id: contact.document.id,
1910
+ name: contact.document.name,
1911
+ accountIds: contact.accountIds,
1912
+ })),
1913
+ accountIds,
1914
+ };
1915
+ }
1916
+ function createImportedSourceBody(source, contactIds) {
1917
+ return {
1918
+ name: source.name,
1919
+ ...(source.url === undefined ? {} : { url: source.url }),
1920
+ ...(source.description === undefined ? {} : { description: source.description }),
1921
+ ...(source.imageUrl === undefined ? {} : { imageUrl: source.imageUrl }),
1922
+ ...(source.faviconUrl === undefined ? {} : { faviconUrl: source.faviconUrl }),
1923
+ ...(contactIds.length === 0 ? {} : { contactIds }),
1924
+ };
1925
+ }
1926
+ async function importAccounts(client, accounts, ownerType, ownerId, path, totals) {
1927
+ const ids = [];
1928
+ for (const [index, account] of accounts.entries()) {
1929
+ const response = await requestSourceImport(client, "/accounts", createImportedAccountBody(account, ownerType, ownerId), `${path}[${index}]`);
1930
+ ids.push(response.data.id);
1931
+ totals.accounts += 1;
1932
+ }
1933
+ return ids;
1934
+ }
1935
+ function createImportedAccountBody(account, ownerType, ownerId) {
1936
+ return {
1937
+ ownerType,
1938
+ ownerId,
1939
+ label: account.label,
1940
+ url: account.url,
1941
+ ...(account.avatarUrl === undefined ? {} : { avatarUrl: account.avatarUrl }),
1942
+ ...(account.kind === undefined ? {} : { kind: account.kind }),
1943
+ ...(account.protocol === undefined ? {} : { protocol: account.protocol }),
1944
+ ...(account.default === undefined ? {} : { isDefault: account.default }),
1945
+ ...(account.sortOrder === undefined ? {} : { sortOrder: account.sortOrder }),
1946
+ };
1947
+ }
1948
+ async function requestSourceImport(client, path, body, importPath) {
1949
+ try {
1950
+ return await client.requestJson({ method: "POST", path, body });
1951
+ }
1952
+ catch (error) {
1953
+ if (error instanceof CliError) {
1954
+ throw new CliError(`Failed to import ${importPath}: ${error.message}`, error.exitCode, error.status);
1955
+ }
1956
+ throw error;
1957
+ }
1958
+ }
1959
+ function writeSourceImportSummary(writer, totals) {
1960
+ writer.stdout(`Imported ${formatImportCount(totals.sources, "source")}, ${formatImportCount(totals.contacts, "contact")}, and ${formatImportCount(totals.accounts, "account")}.`);
1961
+ }
1962
+ function formatImportCount(count, label) {
1963
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
1964
+ }
1717
1965
  async function runSourcesEditCommand(context, args) {
1718
1966
  const idArg = args[0];
1719
1967
  if (idArg === undefined || idArg.startsWith("--")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evcraddock/slug-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Command-line tool for Slugkit sites.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@evcraddock/slug-core": "0.1.1",
30
- "adm-zip": "^0.5.18"
30
+ "adm-zip": "^0.5.18",
31
+ "yaml": "^2.9.0"
31
32
  }
32
33
  }