@evcraddock/slug-cli 0.6.2 → 0.6.4

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
@@ -25,7 +25,7 @@ Create a standalone site with:
25
25
  slug init ./my-site --name my-site --site-title "My Site"
26
26
  ```
27
27
 
28
- Run `slug init --help` for template options. By default, `slug init` consumes the package-backed `@evcraddock/slug-template` default template. `--template-dir` and `--template-url` remain available for local or remote template testing, and `--template` reserves the selection path for future named or external template packages.
28
+ Run `slug init --help` for template options. By default, `slug init` downloads the default template zip artifact. `--template-dir` remains available for local template development, `--template-url` supports alternate remote zip artifacts, and `--template` reserves the selection path for future named templates.
29
29
 
30
30
  ## Login and configuration
31
31
 
@@ -51,7 +51,7 @@ Use `slug --site <name> site config show` to read supported runtime site configu
51
51
 
52
52
  Use `slug --site <name> doctor` to check API compatibility and package metadata. Use `slug --site <name> upgrade` for advisory, non-destructive package update recommendations; it reports suggested dependency changes without rewriting site files.
53
53
 
54
- Use `slug --site <name> post list`, `slug --site <name> post show <slug>`, `slug --site <name> post create`, `slug --site <name> post edit <slug>`, `slug --site <name> post publish <slug>`, `slug --site <name> post unpublish <slug>`, and `slug --site <name> post delete <slug>` to manage posts through the Slugkit posts API.
54
+ Use `slug --site <name> post list`, `slug --site <name> post show <slug>`, `slug --site <name> post create`, `slug --site <name> post edit <slug>`, `slug --site <name> post publish <slug>`, `slug --site <name> post unpublish <slug>`, and `slug --site <name> post delete <slug>` to manage posts through the Slugkit posts API. For migration imports, pass `--published-at <date-or-datetime>` to `post create` or `post publish` to preserve an original publish date without triggering follower delivery by default.
55
55
 
56
56
  Use `slug tag list` to list tags and post usage counts through the Slugkit tags API.
57
57
 
@@ -9,7 +9,6 @@ export interface CommandContext {
9
9
  fetchImpl?: typeof fetch;
10
10
  openUrl?: (url: string) => Promise<void> | void;
11
11
  prompt?: (message: string) => Promise<string> | string;
12
- templateSiteDirectory?: string | null;
13
12
  templateAssetBaseUrl?: string;
14
13
  }
15
14
  export interface CommandResult {
package/dist/commands.js CHANGED
@@ -1,11 +1,8 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
2
  import { tmpdir } from "node:os";
4
3
  import { dirname, basename, extname, join, resolve } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { x as extractTarball } from "tar";
4
+ import AdmZip from "adm-zip";
7
5
  import { SLUGKIT_API_MAJOR_VERSION, SLUGKIT_API_NAME, compareSlugkitApiVersions, normalizeApiBaseUrl, readSlugkitApiMajorVersion, } from "@evcraddock/slug-core";
8
- import { GENERATED_SITE_SLUGKIT_DEPENDENCIES, SLUGKIT_TEMPLATE_PACKAGE_NAME, getTemplateMetadata, getTemplateRoot, isTemplateEntryIncluded, } from "@evcraddock/slug-template";
9
6
  import { isValidSiteName, readConfig, removeConfigSite, setConfigSiteApiBaseUrl, setConfigSiteApiKey, toDisplayConfig, writeConfig, } from "./config.js";
10
7
  import { CliError, createInvalidUsageError, ExitCode } from "./errors.js";
11
8
  import { SlugHttpClient } from "./http.js";
@@ -22,10 +19,10 @@ Usage:
22
19
  slug [--config <file>] init <directory> --name <name> [--site-title <title>] [--template <name>] [--template-url <url>] [--template-dir <dir>] [--json]
23
20
  slug [--config <file>] --site <name> post list [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json]
24
21
  slug [--config <file>] post show <slug> [--json]
25
- slug [--config <file>] post create --type article|link|note --slug <slug> --content <text> [--title <text>] [--url <url>] [--excerpt <text>] [--tag <slug>]... [--source-id <id>] [--credit-contact-id <id>]... [--json]
26
- slug [--config <file>] post edit <slug> [--slug <new-slug>] [--title <text>] [--content <text>] [--url <url>] [--excerpt <text>] [--tag <slug>]... [--source-id <id>] [--credit-contact-id <id>]... [--json]
22
+ slug [--config <file>] post create --type article|link|note --slug <slug> --content <text> [--title <text>] [--url <url>] [--excerpt <text>] [--published-at <datetime>] [--tag <slug>]... [--source-id <id>] [--credit-contact-id <id>]... [--json]
23
+ slug [--config <file>] post edit <slug> [--slug <new-slug>] [--title <text>] [--content <text>] [--url <url>] [--excerpt <text>] [--published-at <datetime>] [--tag <slug>]... [--source-id <id>] [--credit-contact-id <id>]... [--json]
27
24
  slug [--config <file>] post delete <slug> [--json]
28
- slug [--config <file>] post publish <slug> [--json]
25
+ slug [--config <file>] post publish <slug> [--published-at <datetime>] [--json]
29
26
  slug [--config <file>] post unpublish <slug> [--json]
30
27
  slug [--config <file>] comment list [--post <slug>] [--status pending|approved|hidden|all] [--json]
31
28
  slug [--config <file>] comment approve <id> [--json]
@@ -134,9 +131,16 @@ async function runVersionCommand(context, args) {
134
131
  return { exitCode: ExitCode.Ok };
135
132
  }
136
133
  const SUPPORTED_API_MAJOR_VERSION = SLUGKIT_API_MAJOR_VERSION;
137
- const SITE_TEMPLATE_PACKAGE_NAME = getTemplateMetadata().templateSitePackageName;
138
- const DEFAULT_TEMPLATE_METADATA = getTemplateMetadata();
139
- const DEFAULT_TEMPLATE_NAME = DEFAULT_TEMPLATE_METADATA.name;
134
+ const SITE_TEMPLATE_PACKAGE_NAME = "@slugkit/template-site";
135
+ const DEFAULT_TEMPLATE_NAME = "default";
136
+ const DEFAULT_TEMPLATE_URL = "https://forge.caradoc.com/erik/slugkit/releases/download/slugkit-template-latest/slugkit-site-template.zip";
137
+ const GENERATED_SITE_SLUGKIT_DEPENDENCIES = {
138
+ "@evcraddock/slug-api": "0.1.1",
139
+ "@evcraddock/slug-auth": "0.1.0",
140
+ "@evcraddock/slug-core": "0.1.1",
141
+ "@evcraddock/slug-federation": "0.1.0",
142
+ "@evcraddock/slug-media": "0.1.0",
143
+ };
140
144
  const INIT_HELP_TEXT = `slug init - create a standalone Slugkit-compatible website
141
145
 
142
146
  Usage:
@@ -146,12 +150,12 @@ Options:
146
150
  --name <name> npm package name for the generated site.
147
151
  --site-title <title> Human-readable site title. Defaults to a title derived from --name.
148
152
  --template <name> Template name or package. Defaults to ${DEFAULT_TEMPLATE_NAME}.
149
- --template-url <url> Download a template tarball from a custom URL.
153
+ --template-url <url> Download a template zip artifact from a custom URL.
150
154
  --template-dir <dir> Copy a template from a local directory.
151
155
  --json Print command output as JSON.
152
156
  --help, -h Show this help.
153
157
 
154
- By default, slug init uses ${SLUGKIT_TEMPLATE_PACKAGE_NAME}. Template packages are initialization sources only; generated sites do not depend on them at runtime.`;
158
+ By default, slug init downloads the default template zip artifact. Set SLUGKIT_TEMPLATE_URL or pass --template-url to use a different zip artifact. Generated sites do not depend on the template artifact at runtime.`;
155
159
  const SITE_INIT_NEXT_STEPS = [
156
160
  "Install dependencies",
157
161
  "Copy and edit .env",
@@ -331,8 +335,18 @@ async function copyTemplateSite(source, destination, relativeDirectory = "") {
331
335
  }
332
336
  }
333
337
  }
334
- function shouldExcludeTemplateEntry(relativePath, _name) {
335
- return !isTemplateEntryIncluded(relativePath);
338
+ function shouldExcludeTemplateEntry(relativePath, name) {
339
+ const parts = relativePath.split(/[\\/]+/u).filter(Boolean);
340
+ return (parts.includes("node_modules") ||
341
+ parts.includes("dist") ||
342
+ parts.includes("build") ||
343
+ parts.includes("data") ||
344
+ parts.includes("coverage") ||
345
+ relativePath === "src/__tests__/dev-environment.test.ts" ||
346
+ relativePath === "src/federation/__tests__/follow.test.ts" ||
347
+ relativePath === ".env" ||
348
+ name.endsWith(".log") ||
349
+ name.endsWith(".tsbuildinfo"));
336
350
  }
337
351
  async function replaceGeneratedSitePlaceholders(directory, options) {
338
352
  await updateJsonFile(join(directory, "package.json"), (value) => ({
@@ -394,20 +408,20 @@ async function writeGeneratedSiteMarker(directory, options) {
394
408
  slugkitSite: true,
395
409
  name: options.name,
396
410
  siteTitle: options.siteTitle,
397
- template: `${SLUGKIT_TEMPLATE_PACKAGE_NAME}/${DEFAULT_TEMPLATE_NAME}`,
411
+ template: DEFAULT_TEMPLATE_NAME,
398
412
  generatedAt: new Date().toISOString(),
399
- packageMetadata: createGeneratedSitePackageMetadata(DEFAULT_TEMPLATE_METADATA),
413
+ packageMetadata: createGeneratedSitePackageMetadata(),
400
414
  };
401
415
  await writeFile(join(directory, ".slugkit-site.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
402
416
  }
403
- function createGeneratedSitePackageMetadata(metadata) {
417
+ function createGeneratedSitePackageMetadata() {
404
418
  return {
405
419
  template: {
406
- packageName: metadata.packageName,
407
- name: metadata.name,
408
- version: metadata.version,
420
+ packageName: "zip-artifact",
421
+ name: DEFAULT_TEMPLATE_NAME,
422
+ version: "downloaded",
409
423
  },
410
- packages: { ...metadata.includedSlugkitPackages },
424
+ packages: { ...GENERATED_SITE_SLUGKIT_DEPENDENCIES },
411
425
  advisoryOnly: true,
412
426
  migrationNotesUrl: "https://slugkit.com/docs/package-updates",
413
427
  };
@@ -421,20 +435,20 @@ async function resolveInitTemplateSource(context, options) {
421
435
  if (options.templateUrl !== undefined) {
422
436
  return downloadTemplateSite(context, options.templateUrl);
423
437
  }
424
- if (options.template !== undefined) {
425
- return resolveNamedTemplateSource(options.template);
438
+ if (options.template !== undefined && options.template !== DEFAULT_TEMPLATE_NAME) {
439
+ throw createInvalidUsageError(`Unsupported template: ${options.template}. Use --template-url for external template zip artifacts.`);
426
440
  }
427
- const localTemplateDirectory = findTemplateSiteDirectory(context.templateSiteDirectory);
428
- if (localTemplateDirectory !== undefined) {
429
- return { directory: localTemplateDirectory };
430
- }
431
- return { directory: getTemplateRoot() };
441
+ return downloadTemplateSite(context, getDefaultTemplateUrl(context));
432
442
  }
433
- function resolveNamedTemplateSource(template) {
434
- if (template === DEFAULT_TEMPLATE_NAME || template === SLUGKIT_TEMPLATE_PACKAGE_NAME) {
435
- return { directory: getTemplateRoot() };
443
+ function getDefaultTemplateUrl(context) {
444
+ if (context.templateAssetBaseUrl !== undefined) {
445
+ return `${context.templateAssetBaseUrl.replace(/\/+$/u, "")}/slugkit-site-template.zip`;
446
+ }
447
+ const environmentUrl = process.env.SLUGKIT_TEMPLATE_URL?.trim();
448
+ if (environmentUrl !== undefined && environmentUrl !== "") {
449
+ return environmentUrl;
436
450
  }
437
- throw createInvalidUsageError(`Unsupported template: ${template}. This CLI currently includes ${DEFAULT_TEMPLATE_NAME}; future releases may resolve external template packages.`);
451
+ return DEFAULT_TEMPLATE_URL;
438
452
  }
439
453
  async function downloadTemplateSite(context, templateUrl) {
440
454
  let parsedUrl;
@@ -453,17 +467,42 @@ async function downloadTemplateSite(context, templateUrl) {
453
467
  throw new CliError(`Failed to download Slugkit template from ${parsedUrl.toString()}: HTTP ${response.status}`, ExitCode.NetworkError);
454
468
  }
455
469
  const tempDirectory = await mkdtemp(join(tmpdir(), "slug-template-"));
456
- const archivePath = join(tempDirectory, "template.tgz");
470
+ const archivePath = join(tempDirectory, "template.zip");
457
471
  const extractDirectory = join(tempDirectory, "template");
458
472
  await mkdir(extractDirectory, { recursive: true });
459
473
  await writeFile(archivePath, Buffer.from(await response.arrayBuffer()));
460
- await extractTarball({ file: archivePath, cwd: extractDirectory });
461
- await assertTemplateSiteDirectory(extractDirectory);
474
+ new AdmZip(archivePath).extractAllTo(extractDirectory, true);
475
+ const templateDirectory = await findExtractedTemplateDirectory(extractDirectory);
476
+ await assertTemplateSiteDirectory(templateDirectory);
462
477
  return {
463
- directory: extractDirectory,
478
+ directory: templateDirectory,
464
479
  cleanup: () => rm(tempDirectory, { force: true, recursive: true }),
465
480
  };
466
481
  }
482
+ async function findExtractedTemplateDirectory(extractDirectory) {
483
+ if (await isTemplateSiteDirectory(extractDirectory)) {
484
+ return extractDirectory;
485
+ }
486
+ const entries = await readdir(extractDirectory, { withFileTypes: true });
487
+ for (const entry of entries) {
488
+ if (!entry.isDirectory())
489
+ continue;
490
+ const candidate = join(extractDirectory, entry.name);
491
+ if (await isTemplateSiteDirectory(candidate)) {
492
+ return candidate;
493
+ }
494
+ }
495
+ return extractDirectory;
496
+ }
497
+ async function isTemplateSiteDirectory(directory) {
498
+ try {
499
+ const packageJson = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
500
+ return packageJson.name === SITE_TEMPLATE_PACKAGE_NAME;
501
+ }
502
+ catch {
503
+ return false;
504
+ }
505
+ }
467
506
  async function assertTemplateSiteDirectory(directory) {
468
507
  try {
469
508
  const packageJson = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
@@ -478,32 +517,6 @@ async function assertTemplateSiteDirectory(directory) {
478
517
  throw createInvalidUsageError(`Template directory is not readable: ${directory}`);
479
518
  }
480
519
  }
481
- function findTemplateSiteDirectory(templateSiteDirectory) {
482
- if (templateSiteDirectory !== undefined) {
483
- if (templateSiteDirectory === null) {
484
- return undefined;
485
- }
486
- return templateSiteDirectory;
487
- }
488
- const commandsDirectory = dirname(fileURLToPath(import.meta.url));
489
- const candidates = [
490
- resolve(commandsDirectory, "..", "..", "template", "site"),
491
- resolve(process.cwd(), "template", "site"),
492
- resolve(process.cwd(), "..", "template", "site"),
493
- ];
494
- for (const candidate of candidates) {
495
- try {
496
- const packageJson = JSON.parse(readFileSync(join(candidate, "package.json"), "utf8"));
497
- if (packageJson.name === SITE_TEMPLATE_PACKAGE_NAME) {
498
- return candidate;
499
- }
500
- }
501
- catch {
502
- // Try the next candidate.
503
- }
504
- }
505
- return undefined;
506
- }
507
520
  function isNodeErrorWithCode(error, code) {
508
521
  return isRecord(error) && error.code === code;
509
522
  }
@@ -920,6 +933,7 @@ async function runPostsCreateCommand(context, args) {
920
933
  "tag",
921
934
  "source-id",
922
935
  "credit-contact-id",
936
+ "published-at",
923
937
  ]);
924
938
  const body = createPostMutationInput(parsed.options, true);
925
939
  const api = await createConfiguredApiContext(context);
@@ -946,6 +960,7 @@ async function runPostsEditCommand(context, args) {
946
960
  "tag",
947
961
  "source-id",
948
962
  "credit-contact-id",
963
+ "published-at",
949
964
  ]);
950
965
  const body = createPostMutationInput(parsed.options, false);
951
966
  if (Object.keys(body).length === 0) {
@@ -975,12 +990,16 @@ async function runPostsDeleteCommand(context, args) {
975
990
  return { exitCode: ExitCode.Ok };
976
991
  }
977
992
  async function runPostsLifecycleCommand(context, args, action) {
978
- const { slug, json } = readSlugCommandArgs(args, `Usage: slug post ${action} <slug> [--json]`);
993
+ const usage = action === "publish"
994
+ ? "Usage: slug post publish <slug> [--published-at <datetime>] [--json]"
995
+ : "Usage: slug post unpublish <slug> [--json]";
996
+ const { slug, json, publishedAt } = readPostLifecycleArgs(args, usage, action);
979
997
  const api = await createConfiguredApiContext(context);
980
998
  writeMutationTarget(context.writer, api.apiBaseUrl, json);
981
999
  const response = await api.client.requestJson({
982
1000
  method: "POST",
983
1001
  path: `/posts/${encodeURIComponent(slug)}/${action}`,
1002
+ body: publishedAt === undefined ? undefined : { publishedAt },
984
1003
  });
985
1004
  writePostMutationOutput(context.writer, response, json);
986
1005
  return { exitCode: ExitCode.Ok };
@@ -1025,6 +1044,9 @@ function createPostMutationInput(options, requireCreateFields) {
1025
1044
  copyStringOption(options, input, "url", "url");
1026
1045
  copyStringOption(options, input, "excerpt", "excerpt");
1027
1046
  copyNumberOption(options, input, "source-id", "sourceId");
1047
+ if (typeof options["published-at"] === "string") {
1048
+ input.publishedAt = normalizeCliPublishedAt(options["published-at"]);
1049
+ }
1028
1050
  if (Array.isArray(options.tag)) {
1029
1051
  input.tagSlugs = options.tag;
1030
1052
  }
@@ -1065,6 +1087,37 @@ function readSlugCommandArgs(args, usage) {
1065
1087
  }
1066
1088
  return { slug: positional[0], json };
1067
1089
  }
1090
+ function readPostLifecycleArgs(args, usage, action) {
1091
+ if (action === "unpublish") {
1092
+ const { slug, json } = readSlugCommandArgs(args, usage);
1093
+ return { slug, json };
1094
+ }
1095
+ const slug = args[0];
1096
+ if (slug === undefined || slug.startsWith("--")) {
1097
+ throw createInvalidUsageError(usage);
1098
+ }
1099
+ const parsed = parsePostFlags(args.slice(1), ["published-at"]);
1100
+ return {
1101
+ slug,
1102
+ json: parsed.json,
1103
+ publishedAt: typeof parsed.options["published-at"] === "string"
1104
+ ? normalizeCliPublishedAt(parsed.options["published-at"])
1105
+ : undefined,
1106
+ };
1107
+ }
1108
+ function normalizeCliPublishedAt(value) {
1109
+ const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value);
1110
+ const isDateTime = /^\d{4}-\d{2}-\d{2}T/.test(value);
1111
+ if (!isDateOnly && !isDateTime) {
1112
+ throw createInvalidUsageError("--published-at must be a valid ISO date or datetime");
1113
+ }
1114
+ const normalizedInput = isDateOnly ? `${value}T00:00:00.000Z` : value;
1115
+ const date = new Date(normalizedInput);
1116
+ if (Number.isNaN(date.valueOf())) {
1117
+ throw createInvalidUsageError("--published-at must be a valid ISO date or datetime");
1118
+ }
1119
+ return date.toISOString();
1120
+ }
1068
1121
  function addOptionalQuery(query, key, value) {
1069
1122
  if (typeof value === "string") {
1070
1123
  query.set(key, value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evcraddock/slug-cli",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Command-line tool for Slugkit sites.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,7 +27,6 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@evcraddock/slug-core": "0.1.1",
30
- "@evcraddock/slug-template": "0.1.2",
31
- "tar": "^7.5.16"
30
+ "adm-zip": "^0.5.18"
32
31
  }
33
32
  }