@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 +27 -0
- package/dist/commands.js +249 -1
- package/package.json +3 -2
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.
|
|
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
|
}
|