@evcraddock/slug-cli 0.5.0 → 0.6.1

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,22 +25,43 @@ 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. In repository-local development, `slug init` copies `template/site` directly. Installed CLI releases download the matching versioned template asset from the Forgejo release unless `--template-url` or `--template-dir` is provided.
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.
29
29
 
30
30
  ## Login and configuration
31
31
 
32
- Run `slug login` to open the site's browser auth flow, paste the generated API key, verify it, and save the config. Use `slug doctor` to check configured site reachability and compatibility. Use `slug --config ./dev.slug.yaml login` to run with a specific config file.
32
+ The CLI stores named Slug API websites. There is no default or current site; API-backed commands require `--site <name>` so the target is explicit.
33
33
 
34
- The CLI stores YAML config at `${XDG_CONFIG_HOME:-~/.config}/slug/config.yaml` by default. Tests and isolated runs can set `SLUG_CONFIG_HOME` to override the config directory. `slug config show` reports whether an API key is configured without printing the key.
34
+ Add a site and API key directly:
35
35
 
36
- Use `slug site config show` to read supported runtime site configuration through the Slugkit API. Use `slug site config set <field> <value>` to update supported fields such as `name`, `url`, `tagline`, `description`, `homepage.intro`, and `homepage.body`.
36
+ ```bash
37
+ slug config site add slugkit --api-base-url https://slugkit.com/api/v1 --api-key <key>
38
+ slug --site slugkit doctor
39
+ ```
40
+
41
+ Or use browser-assisted login for a named site:
42
+
43
+ ```bash
44
+ slug --site slugkit login https://slugkit.com/api/v1
45
+ slug --site slugkit doctor
46
+ ```
47
+
48
+ The CLI stores YAML config at `${XDG_CONFIG_HOME:-~/.config}/slug/config.yaml` by default. Tests and isolated runs can set `SLUG_CONFIG_HOME` to override the config directory. `slug config show` and `slug config site list` report whether API keys are configured without printing key values.
49
+
50
+ Use `slug --site <name> site config show` to read supported runtime site configuration through the Slugkit API. Use `slug --site <name> site config set <field> <value>` to update supported fields such as `name`, `url`, `tagline`, `description`, `homepage.intro`, and `homepage.body`.
51
+
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.
37
53
 
38
- Use `slug post list`, `slug post show <slug>`, `slug post create`, `slug post edit <slug>`, `slug post publish <slug>`, `slug post unpublish <slug>`, and `slug 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.
39
55
 
40
56
  Use `slug tag list` to list tags and post usage counts through the Slugkit tags API.
41
57
 
42
58
  Example config:
43
59
 
44
60
  ```yaml
45
- apiBaseUrl: https://example.com/api/v1
61
+ sites:
62
+ slugkit:
63
+ apiBaseUrl: https://slugkit.com/api/v1
64
+ apiKey: slug_...
65
+ testapp:
66
+ apiBaseUrl: http://localhost:3000/api/v1
46
67
  ```
@@ -3,6 +3,7 @@ import { type OutputWriter } from "./output.js";
3
3
  export interface CommandContext {
4
4
  args: string[];
5
5
  configPath: string;
6
+ siteName?: string;
6
7
  packageVersion: string;
7
8
  writer: OutputWriter;
8
9
  fetchImpl?: typeof fetch;
package/dist/commands.js CHANGED
@@ -4,20 +4,23 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, basename, extname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { x as extractTarball } from "tar";
7
- import { readConfig, setConfigApiBaseUrl, setConfigApiKey, toDisplayConfig, writeConfig, } from "./config.js";
7
+ 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
+ import { isValidSiteName, readConfig, removeConfigSite, setConfigSiteApiBaseUrl, setConfigSiteApiKey, toDisplayConfig, writeConfig, } from "./config.js";
8
10
  import { CliError, createInvalidUsageError, ExitCode } from "./errors.js";
9
11
  import { SlugHttpClient } from "./http.js";
10
12
  import { writeJson } from "./output.js";
11
13
  const HELP_TEXT = `slug - manage Slugkit sites
12
14
 
13
15
  Usage:
14
- slug [--config <file>] --help
15
- slug [--config <file>] --version
16
- slug [--config <file>] version [--json]
17
- slug [--config <file>] doctor [--json]
18
- slug [--config <file>] login
19
- slug [--config <file>] init <directory> --name <name> [--site-title <title>] [--template-url <url>] [--template-dir <dir>] [--json]
20
- slug [--config <file>] post list [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json]
16
+ slug [--config <file>] [--site <name>] --help
17
+ slug [--config <file>] [--site <name>] --version
18
+ slug [--config <file>] [--site <name>] version [--json]
19
+ slug [--config <file>] --site <name> doctor [--json]
20
+ slug [--config <file>] --site <name> upgrade [--json]
21
+ slug [--config <file>] --site <name> login [api-base-url]
22
+ slug [--config <file>] init <directory> --name <name> [--site-title <title>] [--template <name>] [--template-url <url>] [--template-dir <dir>] [--json]
23
+ slug [--config <file>] --site <name> post list [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json]
21
24
  slug [--config <file>] post show <slug> [--json]
22
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]
23
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]
@@ -53,14 +56,19 @@ Usage:
53
56
  slug [--config <file>] contact create --name <name> [--url <url>] [--json]
54
57
  slug [--config <file>] contact edit <id> [--name <name>] [--url <url>] [--json]
55
58
  slug [--config <file>] contact delete <id> [--json]
56
- slug [--config <file>] site config show [--json]
57
- slug [--config <file>] site config set <field> <value> [--json]
59
+ slug [--config <file>] --site <name> site config show [--json]
60
+ slug [--config <file>] --site <name> site config set <field> <value> [--json]
58
61
  slug [--config <file>] config show [--json]
59
- slug [--config <file>] config set api-base-url <url>
60
- slug [--config <file>] config set api-key <key>
62
+ slug [--config <file>] config site add <name> --api-base-url <url> [--api-key <key>]
63
+ slug [--config <file>] config site set-api-base-url <name> <url>
64
+ slug [--config <file>] config site set-api-key <name> <key>
65
+ slug [--config <file>] config site list [--json]
66
+ slug [--config <file>] config site show <name> [--json]
67
+ slug [--config <file>] config site remove <name>
61
68
 
62
69
  Options:
63
70
  --config <file> Use a specific YAML config file.
71
+ --site <name> Target a named configured Slug API website.
64
72
  --help Show this help.
65
73
  --version Show the CLI version.
66
74
  --json Print command output as JSON when supported.`;
@@ -79,6 +87,8 @@ export async function runCommand(context) {
79
87
  return runVersionCommand(context, args.slice(1));
80
88
  case "doctor":
81
89
  return runDoctorCommand(context, args.slice(1));
90
+ case "upgrade":
91
+ return runUpgradeCommand(context, args.slice(1));
82
92
  case "login":
83
93
  return runLoginCommand(context, args.slice(1));
84
94
  case "init":
@@ -123,24 +133,25 @@ async function runVersionCommand(context, args) {
123
133
  }
124
134
  return { exitCode: ExitCode.Ok };
125
135
  }
126
- const SUPPORTED_API_MAJOR_VERSION = 1;
127
- const SITE_TEMPLATE_PACKAGE_NAME = "@slugkit/template-site";
128
- const SITE_TEMPLATE_RELEASE_BASE_URL = "https://forge.caradoc.com/erik/slugkit/releases/download";
136
+ 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;
129
140
  const INIT_HELP_TEXT = `slug init - create a standalone Slugkit-compatible website
130
141
 
131
142
  Usage:
132
- slug init <directory> --name <name> [--site-title <title>] [--template-url <url>] [--template-dir <dir>] [--json]
143
+ slug init <directory> --name <name> [--site-title <title>] [--template <name>] [--template-url <url>] [--template-dir <dir>] [--json]
133
144
 
134
145
  Options:
135
146
  --name <name> npm package name for the generated site.
136
147
  --site-title <title> Human-readable site title. Defaults to a title derived from --name.
148
+ --template <name> Template name or package. Defaults to ${DEFAULT_TEMPLATE_NAME}.
137
149
  --template-url <url> Download a template tarball from a custom URL.
138
150
  --template-dir <dir> Copy a template from a local directory.
139
151
  --json Print command output as JSON.
140
152
  --help, -h Show this help.
141
153
 
142
- By default, slug init uses the repository-local template/site directory when available.
143
- Installed CLI releases download the versioned template asset that matches the CLI version.`;
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.`;
144
155
  const SITE_INIT_NEXT_STEPS = [
145
156
  "Install dependencies",
146
157
  "Copy and edit .env",
@@ -150,14 +161,6 @@ const SITE_INIT_NEXT_STEPS = [
150
161
  "Configure ActivityPub domain and actor settings",
151
162
  "Configure slug and run slug doctor",
152
163
  ];
153
- const TEMPLATE_COPY_EXCLUDES = new Set([
154
- ".env",
155
- "node_modules",
156
- "dist",
157
- "build",
158
- "data",
159
- "coverage",
160
- ]);
161
164
  async function runInitCommand(context, args) {
162
165
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
163
166
  context.writer.stdout(INIT_HELP_TEXT);
@@ -200,6 +203,7 @@ function parseInitArgs(args) {
200
203
  let directory;
201
204
  let name;
202
205
  let siteTitle;
206
+ let template;
203
207
  let templateUrl;
204
208
  let templateDir;
205
209
  let json = false;
@@ -219,6 +223,11 @@ function parseInitArgs(args) {
219
223
  index += 1;
220
224
  continue;
221
225
  }
226
+ if (arg === "--template") {
227
+ template = readRequiredInitOptionValue(args, index, "template");
228
+ index += 1;
229
+ continue;
230
+ }
222
231
  if (arg === "--template-url") {
223
232
  templateUrl = readRequiredInitOptionValue(args, index, "template-url");
224
233
  index += 1;
@@ -233,15 +242,18 @@ function parseInitArgs(args) {
233
242
  throw createInvalidUsageError(`Unsupported option: ${arg}`);
234
243
  }
235
244
  if (directory !== undefined || arg === undefined) {
236
- throw createInvalidUsageError("Usage: slug init <directory> --name <name> [--site-title <title>] [--template-url <url>] [--template-dir <dir>] [--json]");
245
+ throw createInvalidUsageError("Usage: slug init <directory> --name <name> [--site-title <title>] [--template <name>] [--template-url <url>] [--template-dir <dir>] [--json]");
237
246
  }
238
247
  directory = arg;
239
248
  }
240
249
  if (directory === undefined || name === undefined) {
241
- throw createInvalidUsageError("Usage: slug init <directory> --name <name> [--site-title <title>] [--template-url <url>] [--template-dir <dir>] [--json]");
250
+ throw createInvalidUsageError("Usage: slug init <directory> --name <name> [--site-title <title>] [--template <name>] [--template-url <url>] [--template-dir <dir>] [--json]");
242
251
  }
243
252
  const normalizedName = name.trim();
244
253
  validatePackageName(normalizedName);
254
+ if (template !== undefined && (templateUrl !== undefined || templateDir !== undefined)) {
255
+ throw createInvalidUsageError("Use --template, --template-url, or --template-dir, not more than one");
256
+ }
245
257
  if (templateUrl !== undefined && templateDir !== undefined) {
246
258
  throw createInvalidUsageError("Use only one of --template-url or --template-dir");
247
259
  }
@@ -250,6 +262,7 @@ function parseInitArgs(args) {
250
262
  name: normalizedName,
251
263
  siteTitle: siteTitle?.trim() || humanizePackageName(normalizedName),
252
264
  json,
265
+ template: template?.trim() || undefined,
253
266
  templateUrl: templateUrl?.trim() || undefined,
254
267
  templateDir: templateDir?.trim() || undefined,
255
268
  };
@@ -318,20 +331,15 @@ async function copyTemplateSite(source, destination, relativeDirectory = "") {
318
331
  }
319
332
  }
320
333
  }
321
- const GENERATED_SITE_TEST_EXCLUDES = new Set([
322
- join("src", "__tests__", "dev-environment.test.ts"),
323
- join("src", "federation", "__tests__", "follow.test.ts"),
324
- ]);
325
- function shouldExcludeTemplateEntry(relativePath, name) {
326
- return (GENERATED_SITE_TEST_EXCLUDES.has(relativePath) ||
327
- TEMPLATE_COPY_EXCLUDES.has(name) ||
328
- name.endsWith(".log") ||
329
- name.endsWith(".tsbuildinfo"));
334
+ function shouldExcludeTemplateEntry(relativePath, _name) {
335
+ return !isTemplateEntryIncluded(relativePath);
330
336
  }
331
337
  async function replaceGeneratedSitePlaceholders(directory, options) {
332
338
  await updateJsonFile(join(directory, "package.json"), (value) => ({
333
339
  ...value,
334
340
  name: options.name,
341
+ dependencies: replaceGeneratedSiteWorkspaceDependencies(value.dependencies),
342
+ devDependencies: replaceGeneratedSiteWorkspaceDependencies(value.devDependencies),
335
343
  }));
336
344
  await replaceTextFile(join(directory, "src", "config", "site.ts"), [
337
345
  ['name: "slugkit"', `name: ${JSON.stringify(options.siteTitle)}`],
@@ -360,6 +368,16 @@ async function replaceGeneratedSitePlaceholders(directory, options) {
360
368
  ],
361
369
  ]);
362
370
  }
371
+ function replaceGeneratedSiteWorkspaceDependencies(value) {
372
+ if (!isRecord(value))
373
+ return value;
374
+ return Object.fromEntries(Object.entries(value).map(([name, version]) => [
375
+ name,
376
+ version === "workspace:*" && GENERATED_SITE_SLUGKIT_DEPENDENCIES[name] !== undefined
377
+ ? GENERATED_SITE_SLUGKIT_DEPENDENCIES[name]
378
+ : version,
379
+ ]));
380
+ }
363
381
  async function updateJsonFile(path, update) {
364
382
  const value = JSON.parse(await readFile(path, "utf8"));
365
383
  await writeFile(path, `${JSON.stringify(update(value), null, 2)}\n`, "utf8");
@@ -376,11 +394,24 @@ async function writeGeneratedSiteMarker(directory, options) {
376
394
  slugkitSite: true,
377
395
  name: options.name,
378
396
  siteTitle: options.siteTitle,
379
- template: "slugkit/template-site",
397
+ template: `${SLUGKIT_TEMPLATE_PACKAGE_NAME}/${DEFAULT_TEMPLATE_NAME}`,
380
398
  generatedAt: new Date().toISOString(),
399
+ packageMetadata: createGeneratedSitePackageMetadata(DEFAULT_TEMPLATE_METADATA),
381
400
  };
382
401
  await writeFile(join(directory, ".slugkit-site.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
383
402
  }
403
+ function createGeneratedSitePackageMetadata(metadata) {
404
+ return {
405
+ template: {
406
+ packageName: metadata.packageName,
407
+ name: metadata.name,
408
+ version: metadata.version,
409
+ },
410
+ packages: { ...metadata.includedSlugkitPackages },
411
+ advisoryOnly: true,
412
+ migrationNotesUrl: "https://slugkit.com/docs/package-updates",
413
+ };
414
+ }
384
415
  async function resolveInitTemplateSource(context, options) {
385
416
  if (options.templateDir !== undefined) {
386
417
  const directory = resolve(options.templateDir);
@@ -390,20 +421,20 @@ async function resolveInitTemplateSource(context, options) {
390
421
  if (options.templateUrl !== undefined) {
391
422
  return downloadTemplateSite(context, options.templateUrl);
392
423
  }
424
+ if (options.template !== undefined) {
425
+ return resolveNamedTemplateSource(options.template);
426
+ }
393
427
  const localTemplateDirectory = findTemplateSiteDirectory(context.templateSiteDirectory);
394
428
  if (localTemplateDirectory !== undefined) {
395
429
  return { directory: localTemplateDirectory };
396
430
  }
397
- return downloadTemplateSite(context, getDefaultTemplateAssetUrl(context));
398
- }
399
- function getDefaultTemplateAssetUrl(context) {
400
- const version = context.packageVersion.trim().replace(/^v/, "");
401
- const baseUrl = context.templateAssetBaseUrl ?? SITE_TEMPLATE_RELEASE_BASE_URL;
402
- const assetName = getTemplateAssetName(version);
403
- return `${baseUrl.replace(/\/$/u, "")}/v${encodeURIComponent(version)}/${encodeURIComponent(assetName)}`;
431
+ return { directory: getTemplateRoot() };
404
432
  }
405
- function getTemplateAssetName(version) {
406
- return `slugkit-site-template-v${version}.tgz`;
433
+ function resolveNamedTemplateSource(template) {
434
+ if (template === DEFAULT_TEMPLATE_NAME || template === SLUGKIT_TEMPLATE_PACKAGE_NAME) {
435
+ return { directory: getTemplateRoot() };
436
+ }
437
+ throw createInvalidUsageError(`Unsupported template: ${template}. This CLI currently includes ${DEFAULT_TEMPLATE_NAME}; future releases may resolve external template packages.`);
407
438
  }
408
439
  async function downloadTemplateSite(context, templateUrl) {
409
440
  let parsedUrl;
@@ -478,18 +509,20 @@ function isNodeErrorWithCode(error, code) {
478
509
  }
479
510
  async function runDoctorCommand(context, args) {
480
511
  const json = readJsonFlag(args);
481
- const config = await readConfig(context.configPath);
512
+ const siteResult = await readDoctorSiteConfig(context);
482
513
  const checks = [];
483
- if (config.apiBaseUrl === undefined || config.apiBaseUrl.trim() === "") {
484
- checks.push({ name: "config", status: "fail", message: "apiBaseUrl is not configured" });
514
+ if (!siteResult.ok) {
515
+ checks.push({ name: "config", status: "fail", message: siteResult.message });
485
516
  return finishDoctor(context, { ok: false, checks }, json, ExitCode.InvalidUsage);
486
517
  }
518
+ const config = siteResult.site;
487
519
  const apiBaseUrl = normalizeUrl(config.apiBaseUrl);
488
520
  checks.push({ name: "config", status: "pass", message: `apiBaseUrl: ${apiBaseUrl}` });
489
521
  const health = await requestDoctorJson(context, apiBaseUrl, "/health");
490
522
  checks.push(createHealthCheck(health));
491
523
  const meta = await requestDoctorJson(context, apiBaseUrl, "/meta");
492
524
  checks.push(createMetaCheck(meta));
525
+ checks.push(createPackageMetadataCheck(meta));
493
526
  const openapi = await requestDoctorJson(context, apiBaseUrl, "/openapi.json");
494
527
  checks.push(createOpenApiCheck(openapi));
495
528
  if (config.apiKey === undefined || config.apiKey.trim() === "") {
@@ -502,6 +535,17 @@ async function runDoctorCommand(context, args) {
502
535
  const result = { ok: checks.every((check) => check.status !== "fail"), checks };
503
536
  return finishDoctor(context, result, json, getDoctorExitCode(checks));
504
537
  }
538
+ async function readDoctorSiteConfig(context) {
539
+ try {
540
+ return { ok: true, site: await readSelectedSiteConfig(context) };
541
+ }
542
+ catch (error) {
543
+ if (error instanceof CliError) {
544
+ return { ok: false, message: error.message };
545
+ }
546
+ throw error;
547
+ }
548
+ }
505
549
  function finishDoctor(context, result, json, exitCode) {
506
550
  if (json) {
507
551
  writeJson(context.writer, result);
@@ -578,10 +622,10 @@ function createMetaCheck(result) {
578
622
  if (!isMetaDocument(result.data)) {
579
623
  return { name: "meta", status: "fail", message: "Metadata response is invalid" };
580
624
  }
581
- if (result.data.api.name !== "slugkit") {
625
+ if (result.data.api.name !== SLUGKIT_API_NAME) {
582
626
  return { name: "meta", status: "fail", message: "API name is not slugkit" };
583
627
  }
584
- if (readMajorVersion(result.data.api.version) !== SUPPORTED_API_MAJOR_VERSION) {
628
+ if (readSlugkitApiMajorVersion(result.data.api.version) !== SUPPORTED_API_MAJOR_VERSION) {
585
629
  return {
586
630
  name: "meta",
587
631
  status: "fail",
@@ -590,6 +634,35 @@ function createMetaCheck(result) {
590
634
  }
591
635
  return { name: "meta", status: "pass", message: `API version ${result.data.api.version}` };
592
636
  }
637
+ function createPackageMetadataCheck(result) {
638
+ if (!result.ok) {
639
+ return { name: "packages", status: "warn", message: "Package metadata is unavailable" };
640
+ }
641
+ if (!isMetaDocument(result.data) || result.data.packageMetadata === undefined) {
642
+ return {
643
+ name: "packages",
644
+ status: "warn",
645
+ message: "Package metadata is not exposed by this site",
646
+ };
647
+ }
648
+ const metadata = result.data.packageMetadata;
649
+ const stalePackages = findStaleSlugkitPackages(metadata.packages);
650
+ if (stalePackages.length > 0) {
651
+ return {
652
+ name: "packages",
653
+ status: "warn",
654
+ message: `Updates available: ${stalePackages
655
+ .map((entry) => `${entry.packageName} ${entry.currentVersion} → ${entry.recommendedVersion}`)
656
+ .join(", ")}`,
657
+ };
658
+ }
659
+ const packageCount = Object.keys(metadata.packages).length;
660
+ return {
661
+ name: "packages",
662
+ status: "pass",
663
+ message: `${packageCount} Slugkit package versions reported; upgrades are advisory`,
664
+ };
665
+ }
593
666
  function createOpenApiCheck(result) {
594
667
  if (!result.ok) {
595
668
  return { name: "openapi", status: "fail", message: result.message };
@@ -628,13 +701,108 @@ function getDoctorExitCode(checks) {
628
701
  }
629
702
  return ExitCode.ApiError;
630
703
  }
704
+ async function runUpgradeCommand(context, args) {
705
+ const json = readJsonFlag(args);
706
+ const siteResult = await readDoctorSiteConfig(context);
707
+ if (!siteResult.ok) {
708
+ const result = {
709
+ ok: false,
710
+ advisoryOnly: true,
711
+ message: siteResult.message,
712
+ recommendations: [],
713
+ };
714
+ return finishUpgrade(context, result, json, ExitCode.InvalidUsage);
715
+ }
716
+ const apiBaseUrl = normalizeUrl(siteResult.site.apiBaseUrl);
717
+ const meta = await requestDoctorJson(context, apiBaseUrl, "/meta");
718
+ if (!meta.ok) {
719
+ const result = {
720
+ ok: false,
721
+ advisoryOnly: true,
722
+ message: meta.message,
723
+ recommendations: [],
724
+ };
725
+ return finishUpgrade(context, result, json, getDoctorExitCode([{ name: "meta", status: "fail", message: meta.message }]));
726
+ }
727
+ if (!isMetaDocument(meta.data) || meta.data.packageMetadata === undefined) {
728
+ const result = {
729
+ ok: true,
730
+ advisoryOnly: true,
731
+ message: "Package metadata is unavailable; no files were changed.",
732
+ recommendations: [],
733
+ };
734
+ return finishUpgrade(context, result, json, ExitCode.Ok);
735
+ }
736
+ const metadata = meta.data.packageMetadata;
737
+ const recommendations = findStaleSlugkitPackages(metadata.packages);
738
+ const result = {
739
+ ok: true,
740
+ advisoryOnly: true,
741
+ message: recommendations.length === 0
742
+ ? "No Slugkit package updates are currently recommended. No files were changed."
743
+ : "Slugkit package updates are available. Review and apply them manually; no files were changed.",
744
+ recommendations,
745
+ migrationNotesUrl: metadata.migrationNotesUrl,
746
+ };
747
+ if (metadata.template !== undefined) {
748
+ result.template = `${metadata.template.packageName}/${metadata.template.name}@${metadata.template.version}`;
749
+ }
750
+ return finishUpgrade(context, result, json, ExitCode.Ok);
751
+ }
752
+ function finishUpgrade(context, result, json, exitCode) {
753
+ if (json) {
754
+ writeJson(context.writer, result);
755
+ }
756
+ else {
757
+ context.writer.stdout("Slug upgrade: advisory only");
758
+ context.writer.stdout(result.message);
759
+ if (result.template !== undefined) {
760
+ context.writer.stdout(`Template: ${result.template}`);
761
+ }
762
+ if (result.recommendations.length > 0) {
763
+ context.writer.stdout("Recommended package updates:");
764
+ for (const recommendation of result.recommendations) {
765
+ context.writer.stdout(`- ${recommendation.packageName}: ${recommendation.currentVersion} -> ${recommendation.recommendedVersion}`);
766
+ }
767
+ }
768
+ if (result.migrationNotesUrl !== undefined) {
769
+ context.writer.stdout(`Migration notes: ${result.migrationNotesUrl}`);
770
+ }
771
+ }
772
+ return { exitCode };
773
+ }
631
774
  function isMetaDocument(value) {
632
775
  return (isRecord(value) &&
633
776
  isRecord(value.api) &&
634
777
  typeof value.api.name === "string" &&
635
778
  typeof value.api.version === "string" &&
636
779
  typeof value.api.baseUrl === "string" &&
637
- typeof value.api.openapiUrl === "string");
780
+ typeof value.api.openapiUrl === "string" &&
781
+ (value.packageMetadata === undefined || isPackageMetadata(value.packageMetadata)));
782
+ }
783
+ function isPackageMetadata(value) {
784
+ return (isRecord(value) &&
785
+ isRecord(value.packages) &&
786
+ value.advisoryOnly === true &&
787
+ Object.values(value.packages).every((version) => typeof version === "string") &&
788
+ (value.template === undefined ||
789
+ (isRecord(value.template) &&
790
+ typeof value.template.packageName === "string" &&
791
+ typeof value.template.name === "string" &&
792
+ typeof value.template.version === "string")) &&
793
+ (value.migrationNotesUrl === undefined || typeof value.migrationNotesUrl === "string"));
794
+ }
795
+ function findStaleSlugkitPackages(packages) {
796
+ return Object.entries(GENERATED_SITE_SLUGKIT_DEPENDENCIES)
797
+ .map(([packageName, recommendedVersion]) => {
798
+ const currentVersion = packages[packageName];
799
+ if (currentVersion === undefined)
800
+ return undefined;
801
+ if (compareSlugkitApiVersions(currentVersion, recommendedVersion) >= 0)
802
+ return undefined;
803
+ return { packageName, currentVersion, recommendedVersion };
804
+ })
805
+ .filter((entry) => entry !== undefined);
638
806
  }
639
807
  function isOpenApiDocument(value) {
640
808
  return (isRecord(value) &&
@@ -646,10 +814,6 @@ function isOpenApiDocument(value) {
646
814
  function isRecord(value) {
647
815
  return typeof value === "object" && value !== null && !Array.isArray(value);
648
816
  }
649
- function readMajorVersion(value) {
650
- const major = Number.parseInt(value.split(".")[0] ?? "", 10);
651
- return Number.isInteger(major) ? major : undefined;
652
- }
653
817
  function readApiErrorMessage(value) {
654
818
  if (!isRecord(value) || !isRecord(value.error) || typeof value.error.message !== "string") {
655
819
  return undefined;
@@ -658,10 +822,14 @@ function readApiErrorMessage(value) {
658
822
  }
659
823
  async function runLoginCommand(context, args) {
660
824
  if (args.length > 1) {
661
- throw createInvalidUsageError("Usage: slug login [api-base-url]");
825
+ throw createInvalidUsageError("Usage: slug --site <name> login [api-base-url]");
826
+ }
827
+ if (context.siteName === undefined || context.siteName.trim() === "") {
828
+ throw createInvalidUsageError("--site <name> is required for login");
662
829
  }
663
830
  const config = await readConfig(context.configPath);
664
- const apiBaseUrl = normalizeUrl(args[0] ?? config.apiBaseUrl ?? (await promptForValue(context, "API base URL: ")));
831
+ const configuredSite = config.sites[context.siteName];
832
+ const apiBaseUrl = normalizeUrl(args[0] ?? configuredSite?.apiBaseUrl ?? (await promptForValue(context, "API base URL: ")));
665
833
  const authUrl = createBrowserAuthUrl(apiBaseUrl);
666
834
  context.writer.stdout("Open this URL to log in and create an API key:");
667
835
  context.writer.stdout(authUrl);
@@ -685,8 +853,9 @@ async function runLoginCommand(context, args) {
685
853
  fetchImpl: context.fetchImpl,
686
854
  });
687
855
  await client.requestJson({ path: "/auth/check" });
688
- await writeConfig(context.configPath, setConfigApiKey(setConfigApiBaseUrl(config, apiBaseUrl), apiKey));
689
- context.writer.stdout("Login successful. API key saved.");
856
+ const configWithSite = setConfigSiteApiBaseUrl(config, context.siteName, apiBaseUrl);
857
+ await writeConfig(context.configPath, setConfigSiteApiKey(configWithSite, context.siteName, apiKey));
858
+ context.writer.stdout(`Login successful. API key saved for site ${context.siteName}.`);
690
859
  return { exitCode: ExitCode.Ok };
691
860
  }
692
861
  async function runPostsCommand(context, args) {
@@ -2086,31 +2255,44 @@ async function createConfiguredClient(context) {
2086
2255
  return (await createConfiguredApiContext(context)).client;
2087
2256
  }
2088
2257
  async function createConfiguredApiContext(context) {
2089
- const config = await readConfig(context.configPath);
2090
- if (config.apiBaseUrl === undefined || config.apiBaseUrl.trim() === "") {
2091
- throw createInvalidUsageError("apiBaseUrl is not configured");
2092
- }
2093
- if (config.apiKey === undefined || config.apiKey.trim() === "") {
2094
- throw createInvalidUsageError("apiKey is not configured. Run slug login first.");
2258
+ const site = await readSelectedSiteConfig(context);
2259
+ if (site.apiKey === undefined || site.apiKey.trim() === "") {
2260
+ throw createInvalidUsageError(`apiKey is not configured for site ${context.siteName ?? ""}. Run slug --site <name> login first.`);
2095
2261
  }
2096
- const apiBaseUrl = normalizeUrl(config.apiBaseUrl);
2262
+ const apiBaseUrl = normalizeUrl(site.apiBaseUrl);
2097
2263
  return {
2098
2264
  apiBaseUrl,
2099
2265
  client: new SlugHttpClient({
2100
2266
  apiBaseUrl,
2101
- apiKey: config.apiKey,
2267
+ apiKey: site.apiKey,
2102
2268
  fetchImpl: context.fetchImpl,
2103
2269
  }),
2104
2270
  };
2105
2271
  }
2272
+ async function readSelectedSiteConfig(context) {
2273
+ if (context.siteName === undefined || context.siteName.trim() === "") {
2274
+ throw createInvalidUsageError("--site <name> is required for this command");
2275
+ }
2276
+ const config = await readConfig(context.configPath);
2277
+ const site = config.sites[context.siteName];
2278
+ if (site === undefined) {
2279
+ const names = Object.keys(config.sites).sort();
2280
+ const suffix = names.length === 0 ? "No sites are configured." : `Configured sites: ${names.join(", ")}`;
2281
+ throw createInvalidUsageError(`Unknown site: ${context.siteName}. ${suffix}`);
2282
+ }
2283
+ if (site.apiBaseUrl.trim() === "") {
2284
+ throw createInvalidUsageError(`apiBaseUrl is not configured for site ${context.siteName}`);
2285
+ }
2286
+ return site;
2287
+ }
2106
2288
  async function runConfigCommand(context, args) {
2107
2289
  switch (args[0]) {
2108
2290
  case "show":
2109
2291
  return runConfigShowCommand(context, args.slice(1));
2110
- case "set":
2111
- return runConfigSetCommand(context, args.slice(1));
2292
+ case "site":
2293
+ return runConfigSiteCommand(context, args.slice(1));
2112
2294
  default:
2113
- throw createInvalidUsageError("Usage: slug config <show|set>");
2295
+ throw createInvalidUsageError("Usage: slug config <show|site>");
2114
2296
  }
2115
2297
  }
2116
2298
  async function runConfigShowCommand(context, args) {
@@ -2120,33 +2302,158 @@ async function runConfigShowCommand(context, args) {
2120
2302
  if (json) {
2121
2303
  writeJson(context.writer, displayConfig);
2122
2304
  }
2305
+ else if (displayConfig.sites.length === 0) {
2306
+ context.writer.stdout("No sites configured.");
2307
+ }
2123
2308
  else {
2124
- context.writer.stdout(`apiBaseUrl: ${displayConfig.apiBaseUrl ?? "not configured"}`);
2125
- context.writer.stdout(`apiKeyConfigured: ${displayConfig.apiKeyConfigured ? "true" : "false"}`);
2309
+ for (const site of displayConfig.sites) {
2310
+ context.writer.stdout(`${site.name}: ${site.apiBaseUrl} (apiKeyConfigured: ${site.apiKeyConfigured ? "true" : "false"})`);
2311
+ }
2126
2312
  }
2127
2313
  return { exitCode: ExitCode.Ok };
2128
2314
  }
2129
- async function runConfigSetCommand(context, args) {
2130
- const key = args[0];
2131
- const value = args[1];
2132
- if (key === undefined || value === undefined || args.length !== 2) {
2133
- throw createInvalidUsageError("Usage: slug config set <api-base-url|api-key> <value>");
2315
+ async function runConfigSiteCommand(context, args) {
2316
+ switch (args[0]) {
2317
+ case "add":
2318
+ return runConfigSiteAddCommand(context, args.slice(1));
2319
+ case "set-api-base-url":
2320
+ return runConfigSiteSetApiBaseUrlCommand(context, args.slice(1));
2321
+ case "set-api-key":
2322
+ return runConfigSiteSetApiKeyCommand(context, args.slice(1));
2323
+ case "list":
2324
+ return runConfigSiteListCommand(context, args.slice(1));
2325
+ case "show":
2326
+ return runConfigSiteShowCommand(context, args.slice(1));
2327
+ case "remove":
2328
+ return runConfigSiteRemoveCommand(context, args.slice(1));
2329
+ default:
2330
+ throw createInvalidUsageError("Usage: slug config site <add|set-api-base-url|set-api-key|list|show|remove>");
2331
+ }
2332
+ }
2333
+ async function runConfigSiteAddCommand(context, args) {
2334
+ const name = args[0];
2335
+ if (name === undefined) {
2336
+ throw createInvalidUsageError("Usage: slug config site add <name> --api-base-url <url> [--api-key <key>]");
2134
2337
  }
2338
+ validateSiteName(name);
2339
+ const parsed = parseConfigSiteAddArgs(args.slice(1));
2135
2340
  const config = await readConfig(context.configPath);
2136
- switch (key) {
2137
- case "api-base-url":
2138
- validateUrl(value);
2139
- await writeConfig(context.configPath, setConfigApiBaseUrl(config, value));
2140
- break;
2141
- case "api-key":
2142
- await writeConfig(context.configPath, setConfigApiKey(config, value));
2143
- break;
2144
- default:
2145
- throw createInvalidUsageError(`Unknown config key: ${key}`);
2341
+ let nextConfig = setConfigSiteApiBaseUrl(config, name, parsed.apiBaseUrl);
2342
+ if (parsed.apiKey !== undefined) {
2343
+ nextConfig = setConfigSiteApiKey(nextConfig, name, parsed.apiKey);
2344
+ }
2345
+ await writeConfig(context.configPath, nextConfig);
2346
+ context.writer.stdout(`Configured site ${name}.`);
2347
+ return { exitCode: ExitCode.Ok };
2348
+ }
2349
+ async function runConfigSiteSetApiBaseUrlCommand(context, args) {
2350
+ const [name, apiBaseUrl] = args;
2351
+ if (name === undefined || apiBaseUrl === undefined || args.length !== 2) {
2352
+ throw createInvalidUsageError("Usage: slug config site set-api-base-url <name> <url>");
2353
+ }
2354
+ validateSiteName(name);
2355
+ validateUrl(apiBaseUrl);
2356
+ const config = await readConfig(context.configPath);
2357
+ await writeConfig(context.configPath, setConfigSiteApiBaseUrl(config, name, apiBaseUrl));
2358
+ context.writer.stdout(`Updated site ${name} api-base-url.`);
2359
+ return { exitCode: ExitCode.Ok };
2360
+ }
2361
+ async function runConfigSiteSetApiKeyCommand(context, args) {
2362
+ const [name, apiKey] = args;
2363
+ if (name === undefined || apiKey === undefined || args.length !== 2) {
2364
+ throw createInvalidUsageError("Usage: slug config site set-api-key <name> <key>");
2365
+ }
2366
+ validateSiteName(name);
2367
+ const config = await readConfig(context.configPath);
2368
+ if (config.sites[name] === undefined) {
2369
+ throw createInvalidUsageError(`Unknown site: ${name}`);
2370
+ }
2371
+ await writeConfig(context.configPath, setConfigSiteApiKey(config, name, apiKey));
2372
+ context.writer.stdout(`Updated site ${name} api-key.`);
2373
+ return { exitCode: ExitCode.Ok };
2374
+ }
2375
+ async function runConfigSiteListCommand(context, args) {
2376
+ const json = readJsonFlag(args);
2377
+ const config = await readConfig(context.configPath);
2378
+ const sites = toDisplayConfig(config).sites;
2379
+ if (json) {
2380
+ writeJson(context.writer, { sites });
2381
+ }
2382
+ else if (sites.length === 0) {
2383
+ context.writer.stdout("No sites configured.");
2384
+ }
2385
+ else {
2386
+ for (const site of sites) {
2387
+ context.writer.stdout(`${site.name}: ${site.apiBaseUrl} (apiKeyConfigured: ${site.apiKeyConfigured ? "true" : "false"})`);
2388
+ }
2389
+ }
2390
+ return { exitCode: ExitCode.Ok };
2391
+ }
2392
+ async function runConfigSiteShowCommand(context, args) {
2393
+ const json = args.includes("--json");
2394
+ const positional = args.filter((arg) => arg !== "--json");
2395
+ const name = positional[0];
2396
+ if (name === undefined || positional.length !== 1) {
2397
+ throw createInvalidUsageError("Usage: slug config site show <name> [--json]");
2398
+ }
2399
+ const site = toDisplayConfig(await readConfig(context.configPath)).sites.find((candidate) => candidate.name === name);
2400
+ if (site === undefined) {
2401
+ throw createInvalidUsageError(`Unknown site: ${name}`);
2402
+ }
2403
+ if (json) {
2404
+ writeJson(context.writer, site);
2405
+ }
2406
+ else {
2407
+ context.writer.stdout(`name: ${site.name}`);
2408
+ context.writer.stdout(`apiBaseUrl: ${site.apiBaseUrl}`);
2409
+ context.writer.stdout(`apiKeyConfigured: ${site.apiKeyConfigured ? "true" : "false"}`);
2146
2410
  }
2147
- context.writer.stdout(`Updated ${key}.`);
2148
2411
  return { exitCode: ExitCode.Ok };
2149
2412
  }
2413
+ async function runConfigSiteRemoveCommand(context, args) {
2414
+ const name = args[0];
2415
+ if (name === undefined || args.length !== 1) {
2416
+ throw createInvalidUsageError("Usage: slug config site remove <name>");
2417
+ }
2418
+ const config = await readConfig(context.configPath);
2419
+ if (config.sites[name] === undefined) {
2420
+ throw createInvalidUsageError(`Unknown site: ${name}`);
2421
+ }
2422
+ await writeConfig(context.configPath, removeConfigSite(config, name));
2423
+ context.writer.stdout(`Removed site ${name}.`);
2424
+ return { exitCode: ExitCode.Ok };
2425
+ }
2426
+ function parseConfigSiteAddArgs(args) {
2427
+ let apiBaseUrl;
2428
+ let apiKey;
2429
+ for (let index = 0; index < args.length; index += 1) {
2430
+ const arg = args[index];
2431
+ const value = args[index + 1];
2432
+ if ((arg === "--api-base-url" || arg === "--api-key") &&
2433
+ value !== undefined &&
2434
+ !value.startsWith("--")) {
2435
+ if (arg === "--api-base-url") {
2436
+ apiBaseUrl = value;
2437
+ }
2438
+ else {
2439
+ apiKey = value;
2440
+ }
2441
+ index += 1;
2442
+ continue;
2443
+ }
2444
+ throw createInvalidUsageError("Usage: slug config site add <name> --api-base-url <url> [--api-key <key>]");
2445
+ }
2446
+ if (apiBaseUrl === undefined) {
2447
+ throw createInvalidUsageError("Missing required option: --api-base-url");
2448
+ }
2449
+ validateUrl(apiBaseUrl);
2450
+ return { apiBaseUrl, apiKey };
2451
+ }
2452
+ function validateSiteName(name) {
2453
+ if (!isValidSiteName(name)) {
2454
+ throw createInvalidUsageError("Site names may contain only letters, numbers, dots, underscores, and hyphens");
2455
+ }
2456
+ }
2150
2457
  function readJsonFlag(args) {
2151
2458
  if (args.length === 0) {
2152
2459
  return false;
@@ -2176,17 +2483,13 @@ export function createBrowserAuthUrl(apiBaseUrl) {
2176
2483
  return url.toString();
2177
2484
  }
2178
2485
  function normalizeUrl(value) {
2179
- validateUrl(value);
2180
- return value.replace(/\/+$/, "");
2181
- }
2182
- function validateUrl(value) {
2183
2486
  try {
2184
- const url = new URL(value);
2185
- if (url.protocol !== "http:" && url.protocol !== "https:") {
2186
- throw new Error("Unsupported protocol");
2187
- }
2487
+ return normalizeApiBaseUrl(value);
2188
2488
  }
2189
2489
  catch {
2190
2490
  throw createInvalidUsageError("api-base-url must be a valid HTTP or HTTPS URL");
2191
2491
  }
2192
2492
  }
2493
+ function validateUrl(value) {
2494
+ normalizeUrl(value);
2495
+ }
package/dist/config.d.ts CHANGED
@@ -1,17 +1,26 @@
1
- export interface SlugConfig {
2
- apiBaseUrl?: string;
1
+ export interface SlugSiteConfig {
2
+ apiBaseUrl: string;
3
3
  apiKey?: string;
4
4
  apiKeyReference?: string;
5
5
  }
6
- export interface DisplayConfig {
7
- apiBaseUrl?: string;
6
+ export interface SlugConfig {
7
+ sites: Record<string, SlugSiteConfig>;
8
+ }
9
+ export interface DisplaySiteConfig {
10
+ name: string;
11
+ apiBaseUrl: string;
8
12
  apiKeyConfigured: boolean;
9
13
  apiKeyReference?: string;
10
14
  }
15
+ export interface DisplayConfig {
16
+ sites: DisplaySiteConfig[];
17
+ }
11
18
  export declare function createDefaultConfig(): SlugConfig;
12
19
  export declare function getConfigPath(environment?: NodeJS.ProcessEnv): string;
13
20
  export declare function readConfig(configPath: string): Promise<SlugConfig>;
14
21
  export declare function writeConfig(configPath: string, config: SlugConfig): Promise<void>;
15
- export declare function setConfigApiBaseUrl(config: SlugConfig, apiBaseUrl: string): SlugConfig;
16
- export declare function setConfigApiKey(config: SlugConfig, apiKey: string): SlugConfig;
22
+ export declare function setConfigSiteApiBaseUrl(config: SlugConfig, siteName: string, apiBaseUrl: string): SlugConfig;
23
+ export declare function setConfigSiteApiKey(config: SlugConfig, siteName: string, apiKey: string): SlugConfig;
24
+ export declare function removeConfigSite(config: SlugConfig, siteName: string): SlugConfig;
17
25
  export declare function toDisplayConfig(config: SlugConfig): DisplayConfig;
26
+ export declare function isValidSiteName(name: string): boolean;
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  export function createDefaultConfig() {
5
- return {};
5
+ return { sites: {} };
6
6
  }
7
7
  export function getConfigPath(environment = process.env) {
8
8
  const configHome = environment.SLUG_CONFIG_HOME ?? environment.XDG_CONFIG_HOME ?? join(homedir(), ".config");
@@ -24,19 +24,46 @@ export async function writeConfig(configPath, config) {
24
24
  await mkdir(dirname(configPath), { recursive: true });
25
25
  await writeFile(configPath, serializeConfig(config), "utf8");
26
26
  }
27
- export function setConfigApiBaseUrl(config, apiBaseUrl) {
28
- return { ...config, apiBaseUrl };
27
+ export function setConfigSiteApiBaseUrl(config, siteName, apiBaseUrl) {
28
+ const site = config.sites[siteName] ?? { apiBaseUrl };
29
+ return {
30
+ ...config,
31
+ sites: {
32
+ ...config.sites,
33
+ [siteName]: { ...site, apiBaseUrl },
34
+ },
35
+ };
36
+ }
37
+ export function setConfigSiteApiKey(config, siteName, apiKey) {
38
+ const site = config.sites[siteName];
39
+ if (site === undefined) {
40
+ throw new Error(`Site is not configured: ${siteName}`);
41
+ }
42
+ const nextSite = { ...site, apiKey };
43
+ delete nextSite.apiKeyReference;
44
+ return {
45
+ ...config,
46
+ sites: {
47
+ ...config.sites,
48
+ [siteName]: nextSite,
49
+ },
50
+ };
29
51
  }
30
- export function setConfigApiKey(config, apiKey) {
31
- const nextConfig = { ...config, apiKey };
32
- delete nextConfig.apiKeyReference;
33
- return nextConfig;
52
+ export function removeConfigSite(config, siteName) {
53
+ const sites = { ...config.sites };
54
+ delete sites[siteName];
55
+ return { ...config, sites };
34
56
  }
35
57
  export function toDisplayConfig(config) {
36
58
  return {
37
- apiBaseUrl: config.apiBaseUrl,
38
- apiKeyConfigured: config.apiKey !== undefined || config.apiKeyReference !== undefined,
39
- apiKeyReference: config.apiKeyReference,
59
+ sites: Object.entries(config.sites)
60
+ .sort(([left], [right]) => left.localeCompare(right))
61
+ .map(([name, site]) => ({
62
+ name,
63
+ apiBaseUrl: site.apiBaseUrl,
64
+ apiKeyConfigured: site.apiKey !== undefined || site.apiKeyReference !== undefined,
65
+ apiKeyReference: site.apiKeyReference,
66
+ })),
40
67
  };
41
68
  }
42
69
  function parseConfig(content) {
@@ -47,47 +74,75 @@ function parseConfig(content) {
47
74
  if (trimmed.startsWith("{")) {
48
75
  return normalizeParsedConfig(JSON.parse(trimmed));
49
76
  }
50
- const parsed = {};
51
- for (const [index, rawLine] of content.split(/\r?\n/).entries()) {
52
- const line = stripComment(rawLine).trim();
53
- if (line === "" || line === "---") {
77
+ const lines = content.split(/\r?\n/);
78
+ const sites = {};
79
+ let currentSite;
80
+ for (const [index, rawLine] of lines.entries()) {
81
+ const uncommented = stripComment(rawLine);
82
+ const line = uncommented.trimEnd();
83
+ if (line.trim() === "" || line.trim() === "---") {
54
84
  continue;
55
85
  }
56
- const match = /^(apiBaseUrl|apiKey|apiKeyReference):(?:\s*(.*))?$/.exec(line);
57
- if (match === null) {
58
- throw new Error(`Invalid slug config YAML at line ${index + 1}`);
86
+ if (line === "sites:" || line === "sites: {}") {
87
+ currentSite = undefined;
88
+ continue;
89
+ }
90
+ const siteMatch = /^ {2}([A-Za-z0-9._-]+):\s*$/.exec(line);
91
+ if (siteMatch !== null) {
92
+ currentSite = siteMatch[1];
93
+ sites[currentSite] = sites[currentSite] ?? {};
94
+ continue;
59
95
  }
60
- const [, key, rawValue = ""] = match;
61
- parsed[key] = parseScalar(rawValue.trim());
96
+ const fieldMatch = /^ {4}(apiBaseUrl|apiKey|apiKeyReference):(?:\s*(.*))?$/.exec(line);
97
+ if (fieldMatch !== null && currentSite !== undefined) {
98
+ const [, key, rawValue = ""] = fieldMatch;
99
+ sites[currentSite][key] = parseScalar(rawValue.trim());
100
+ continue;
101
+ }
102
+ throw new Error(`Invalid slug config YAML at line ${index + 1}`);
62
103
  }
63
- return normalizeParsedConfig(parsed);
104
+ return normalizeParsedConfig({ sites: sites });
64
105
  }
65
106
  function serializeConfig(config) {
66
- const lines = [];
67
- if (config.apiBaseUrl !== undefined) {
68
- lines.push(`apiBaseUrl: ${formatScalar(config.apiBaseUrl)}`);
69
- }
70
- if (config.apiKey !== undefined) {
71
- lines.push(`apiKey: ${formatScalar(config.apiKey)}`);
72
- }
73
- if (config.apiKeyReference !== undefined) {
74
- lines.push(`apiKeyReference: ${formatScalar(config.apiKeyReference)}`);
107
+ const lines = ["sites:"];
108
+ const entries = Object.entries(config.sites).sort(([left], [right]) => left.localeCompare(right));
109
+ for (const [name, site] of entries) {
110
+ lines.push(` ${name}:`);
111
+ lines.push(` apiBaseUrl: ${formatScalar(site.apiBaseUrl)}`);
112
+ if (site.apiKey !== undefined) {
113
+ lines.push(` apiKey: ${formatScalar(site.apiKey)}`);
114
+ }
115
+ if (site.apiKeyReference !== undefined) {
116
+ lines.push(` apiKeyReference: ${formatScalar(site.apiKeyReference)}`);
117
+ }
75
118
  }
76
- return lines.length === 0 ? "{}\n" : `${lines.join("\n")}\n`;
119
+ return entries.length === 0 ? "sites: {}\n" : `${lines.join("\n")}\n`;
77
120
  }
78
121
  function normalizeParsedConfig(config) {
79
- const normalized = {};
80
- if (typeof config.apiBaseUrl === "string") {
81
- normalized.apiBaseUrl = config.apiBaseUrl;
82
- }
83
- if (typeof config.apiKey === "string") {
84
- normalized.apiKey = config.apiKey;
85
- }
86
- if (typeof config.apiKeyReference === "string") {
87
- normalized.apiKeyReference = config.apiKeyReference;
122
+ const normalized = createDefaultConfig();
123
+ if (config.sites !== undefined && typeof config.sites === "object" && config.sites !== null) {
124
+ for (const [name, site] of Object.entries(config.sites)) {
125
+ if (!isValidSiteName(name) || typeof site !== "object" || site === null) {
126
+ continue;
127
+ }
128
+ const partial = site;
129
+ if (typeof partial.apiBaseUrl !== "string") {
130
+ continue;
131
+ }
132
+ normalized.sites[name] = { apiBaseUrl: partial.apiBaseUrl };
133
+ if (typeof partial.apiKey === "string") {
134
+ normalized.sites[name].apiKey = partial.apiKey;
135
+ }
136
+ if (typeof partial.apiKeyReference === "string") {
137
+ normalized.sites[name].apiKeyReference = partial.apiKeyReference;
138
+ }
139
+ }
88
140
  }
89
141
  return normalized;
90
142
  }
143
+ export function isValidSiteName(name) {
144
+ return /^[A-Za-z0-9._-]+$/u.test(name);
145
+ }
91
146
  function stripComment(line) {
92
147
  let quote;
93
148
  for (let index = 0; index < line.length; index += 1) {
package/dist/http.d.ts CHANGED
@@ -8,13 +8,6 @@ export interface HttpRequestOptions {
8
8
  path: string;
9
9
  body?: unknown;
10
10
  }
11
- export interface ApiErrorBody {
12
- error?: {
13
- code?: string;
14
- message?: string;
15
- operation?: string;
16
- };
17
- }
18
11
  export declare class SlugHttpClient {
19
12
  private readonly apiBaseUrl;
20
13
  private readonly apiKey;
package/dist/http.js CHANGED
@@ -1,10 +1,11 @@
1
+ import { normalizeApiBaseUrl } from "@evcraddock/slug-core";
1
2
  import { CliError, ExitCode } from "./errors.js";
2
3
  export class SlugHttpClient {
3
4
  apiBaseUrl;
4
5
  apiKey;
5
6
  fetchImpl;
6
7
  constructor(options) {
7
- this.apiBaseUrl = options.apiBaseUrl.replace(/\/$/, "");
8
+ this.apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl);
8
9
  this.apiKey = options.apiKey;
9
10
  this.fetchImpl = options.fetchImpl ?? fetch;
10
11
  }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export async function main(args = process.argv.slice(2)) {
15
15
  const result = await runCommand({
16
16
  args: parsedArgs.commandArgs,
17
17
  configPath: parsedArgs.configPath ?? getConfigPath(),
18
+ siteName: parsedArgs.siteName,
18
19
  packageVersion: readPackageVersion(),
19
20
  writer,
20
21
  openUrl,
@@ -60,6 +61,7 @@ async function promptUser(message) {
60
61
  function parseGlobalOptions(args) {
61
62
  const commandArgs = [];
62
63
  let configPath;
64
+ let siteName;
63
65
  for (let index = 0; index < args.length; index += 1) {
64
66
  const arg = args[index];
65
67
  if (arg === "--config") {
@@ -79,9 +81,26 @@ function parseGlobalOptions(args) {
79
81
  configPath = value;
80
82
  continue;
81
83
  }
84
+ if (arg === "--site") {
85
+ const value = args[index + 1];
86
+ if (value === undefined || value.startsWith("--")) {
87
+ throw createInvalidUsageError("Usage: slug --site <name> <command>");
88
+ }
89
+ siteName = value;
90
+ index += 1;
91
+ continue;
92
+ }
93
+ if (arg?.startsWith("--site=")) {
94
+ const value = arg.slice("--site=".length);
95
+ if (value === "") {
96
+ throw createInvalidUsageError("Usage: slug --site <name> <command>");
97
+ }
98
+ siteName = value;
99
+ continue;
100
+ }
82
101
  commandArgs.push(arg);
83
102
  }
84
- return { commandArgs, configPath };
103
+ return { commandArgs, configPath, siteName };
85
104
  }
86
105
  function getOpenCommand(url) {
87
106
  if (process.platform === "darwin") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evcraddock/slug-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Command-line tool for Slugkit sites.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -26,6 +26,8 @@
26
26
  "test:watch": "vitest --config ../vitest.config.ts src"
27
27
  },
28
28
  "dependencies": {
29
+ "@evcraddock/slug-core": "0.1.1",
30
+ "@evcraddock/slug-template": "0.1.1",
29
31
  "tar": "^7.5.16"
30
32
  }
31
33
  }