pageship 0.1.1 → 0.3.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.
Files changed (43) hide show
  1. package/README.md +18 -108
  2. package/dist/cli.mjs +1889 -481
  3. package/dist/skills/LICENSE +21 -0
  4. package/dist/skills/pageship/SKILL.md +150 -0
  5. package/dist/skills/pageship/references/download.md +9 -0
  6. package/dist/skills/pageship/references/list-and-history.md +15 -0
  7. package/dist/skills/pageship/references/restore.md +9 -0
  8. package/dist/skills/pageship/references/visibility.md +12 -0
  9. package/dist/skills/pageship-chart/SKILL.md +110 -0
  10. package/dist/skills/pageship-code-block/SKILL.md +113 -0
  11. package/dist/skills/pageship-code-block/references/highlight-lock.json +542 -0
  12. package/dist/skills/pageship-code-block/references/highlight.mjs +138 -0
  13. package/dist/skills/pageship-decision-record/SKILL.md +43 -0
  14. package/dist/skills/pageship-diagram/SKILL.md +92 -0
  15. package/dist/skills/pageship-diagram/references/check-diagram.js +301 -0
  16. package/dist/skills/pageship-diagram/references/computed-layout.md +49 -0
  17. package/dist/skills/pageship-diagram/references/layout.mjs +310 -0
  18. package/dist/skills/pageship-diagram/references/mermaid.md +89 -0
  19. package/dist/skills/pageship-diagram/references/svg.md +205 -0
  20. package/dist/skills/pageship-docs/NOTICES.md +35 -0
  21. package/dist/skills/pageship-docs/SKILL.md +67 -0
  22. package/dist/skills/pageship-docs/references/ask.md +18 -0
  23. package/dist/skills/pageship-docs/references/check.md +39 -0
  24. package/dist/skills/pageship-docs/references/content.md +29 -0
  25. package/dist/skills/pageship-docs/references/design.md +60 -0
  26. package/dist/skills/pageship-docs/references/file.md +52 -0
  27. package/dist/skills/pageship-docs/references/plain-default.md +331 -0
  28. package/dist/skills/pageship-explainer/SKILL.md +92 -0
  29. package/dist/skills/pageship-interaction/SKILL.md +115 -0
  30. package/dist/skills/pageship-interaction/references/pointer.md +271 -0
  31. package/dist/skills/pageship-interaction/references/step-through.md +146 -0
  32. package/dist/skills/pageship-key-figures/SKILL.md +127 -0
  33. package/dist/skills/pageship-media/SKILL.md +122 -0
  34. package/dist/skills/pageship-motion/SKILL.md +58 -0
  35. package/dist/skills/pageship-plan/SKILL.md +55 -0
  36. package/dist/skills/pageship-report/SKILL.md +29 -0
  37. package/dist/skills/pageship-review-report/SKILL.md +90 -0
  38. package/dist/skills/pageship-slide-deck/SKILL.md +53 -0
  39. package/dist/skills/pageship-slide-deck/references/engine.md +362 -0
  40. package/dist/skills/pageship-spec/SKILL.md +58 -0
  41. package/dist/skills/pageship-table/SKILL.md +243 -0
  42. package/dist/skills/pageship-table/references/sort-and-filter.md +235 -0
  43. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile } from "node:child_process";
3
- import { createHash, randomUUID } from "node:crypto";
4
- import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
3
+ import { access, cp, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
5
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
5
  import { createInterface } from "node:readline/promises";
7
6
  import { promisify } from "node:util";
7
+ import { createHash, randomUUID } from "node:crypto";
8
8
  import { constants } from "node:fs";
9
9
  import { homedir } from "node:os";
10
- import { setTimeout as setTimeout$1 } from "node:timers/promises";
11
- import { NodeServices } from "@effect/platform-node";
12
10
  import { Cause, Config, Console, Effect, Option, Schema } from "effect";
13
- import { Argument, CliConfig, CliError, CliOutput, Command, Flag, GlobalFlag } from "effect/unstable/cli";
11
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
12
+ import * as NodeServices from "@effect/platform-node/NodeServices";
13
+ import { Argument, CliConfig, CliOutput, Command, Flag, GlobalFlag } from "effect/unstable/cli";
14
+ import { fileURLToPath } from "node:url";
14
15
  //#region ../../packages/api-client/src/generated/core/bodySerializer.gen.ts
15
16
  const jsonBodySerializer = { bodySerializer: (body) => JSON.stringify(body, (_key, value) => typeof value === "bigint" ? value.toString() : value) };
16
17
  Object.entries({
@@ -633,6 +634,10 @@ const addressAllocate = (options) => (options.client ?? client).post({
633
634
  ...options.headers
634
635
  }
635
636
  });
637
+ const documentsList = (options) => (options?.client ?? client).get({
638
+ url: "/api/v1/documents",
639
+ ...options
640
+ });
636
641
  const documentsCreate = (options) => (options.client ?? client).post({
637
642
  bodySerializer: null,
638
643
  url: "/api/v1/documents",
@@ -646,8 +651,52 @@ const documentsLimits = (options) => (options?.client ?? client).get({
646
651
  url: "/api/v1/documents/limits",
647
652
  ...options
648
653
  });
654
+ const documentsGet = (options) => (options.client ?? client).get({
655
+ url: "/api/v1/documents/{id}",
656
+ ...options
657
+ });
658
+ const documentsContent = (options) => (options.client ?? client).get({
659
+ url: "/api/v1/documents/{id}/content",
660
+ ...options
661
+ });
662
+ const documentsListRevisions = (options) => (options.client ?? client).get({
663
+ url: "/api/v1/documents/{id}/revisions",
664
+ ...options
665
+ });
666
+ const documentsRevisionContent = (options) => (options.client ?? client).get({
667
+ url: "/api/v1/documents/{id}/revisions/{revisionId}/content",
668
+ ...options
669
+ });
670
+ const documentsRestore = (options) => (options.client ?? client).post({
671
+ url: "/api/v1/documents/{id}/restore",
672
+ ...options,
673
+ headers: {
674
+ "Content-Type": "application/json",
675
+ ...options.headers
676
+ }
677
+ });
678
+ const documentsUpdateVisibility = (options) => (options.client ?? client).patch({
679
+ url: "/api/v1/documents/{id}/visibility",
680
+ ...options,
681
+ headers: {
682
+ "Content-Type": "application/json",
683
+ ...options.headers
684
+ }
685
+ });
686
+ //#endregion
687
+ //#region ../../packages/api-client/src/generated/workspace-domain.gen.ts
688
+ /** Every workspace address and document URL is a subdomain of this domain. */
689
+ const workspaceDomain = "pageship.dev";
690
+ /** The workspace domain escaped for use inside a regular expression. */
691
+ const workspaceDomainPattern = workspaceDomain.replaceAll(".", "\\.");
692
+ /** The hostname of the workspace address with this subdomain label. */
693
+ const workspaceHostname = (subdomain) => `${subdomain}.${workspaceDomain}`;
694
+ /** A valid workspace label: the subdomain of one workspace address, as a DNS label. */
695
+ const workspaceLabelPattern = /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/u;
649
696
  //#endregion
650
697
  //#region ../../packages/api-client/src/address.ts
698
+ /** Generated TypeScript does not validate remote JSON, so check an address before showing it. */
699
+ const isWorkspaceAddress = (value) => typeof value.subdomain === "string" && workspaceLabelPattern.test(value.subdomain) && typeof value.hostname === "string" && value.hostname === workspaceHostname(value.subdomain);
651
700
  async function updateWorkspaceAddress(baseUrl, action, options = {}) {
652
701
  const timeout = AbortSignal.timeout(1e4);
653
702
  const { data, response, error } = await addressAllocate({
@@ -662,9 +711,8 @@ async function updateWorkspaceAddress(baseUrl, action, options = {}) {
662
711
  });
663
712
  if (!response?.ok) throw new Error("Could not update your workspace address. Try again.", { cause: error });
664
713
  if (data?.status === "signed_out" || data?.status === "uninvited" || data?.status === "reauthentication_required") return { status: data.status };
665
- const validAddress = (value) => typeof value.subdomain === "string" && /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/u.test(value.subdomain) && typeof value.hostname === "string" && value.hostname === `${value.subdomain}.pageship.dev`;
666
- if (data?.status === "allocated" && validAddress(data)) return data;
667
- if (data?.status === "choice_required" && ["unavailable", "stale_choice"].includes(data.reason) && Array.isArray(data.choices) && data.choices.every((choice) => choice && typeof choice.choiceId === "string" && choice.choiceId.length > 0 && validAddress(choice))) return data;
714
+ if (data?.status === "allocated" && isWorkspaceAddress(data)) return data;
715
+ if (data?.status === "choice_required" && ["unavailable", "stale_choice"].includes(data.reason) && Array.isArray(data.choices) && data.choices.every((choice) => choice && typeof choice.choiceId === "string" && choice.choiceId.length > 0 && isWorkspaceAddress(choice))) return data;
668
716
  throw new Error("Invalid workspace address response.");
669
717
  }
670
718
  //#endregion
@@ -675,6 +723,13 @@ function isDocumentId(value) {
675
723
  function isRevisionId(value) {
676
724
  return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?![\s\S])/iu.test(value);
677
725
  }
726
+ /** Same readable normalized Git remote grammar as the Effect contract. */
727
+ function isRepository(value) {
728
+ return typeof value === "string" && value.length <= 512 && /^[a-z0-9._-]+(?::[0-9]{1,5})?(?:\/(?!\.{1,2}(?:\/|$))[^\x00-\x20\x7f/\\?#]+)+(?![\s\S])/u.test(value);
729
+ }
730
+ function isCliVersion(value) {
731
+ return typeof value === "string" && /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}(?![\s\S])/u.test(value);
732
+ }
678
733
  function requestOptions(baseUrl, options) {
679
734
  const timeout = AbortSignal.timeout(3e4);
680
735
  return {
@@ -702,7 +757,10 @@ function normalizeDocument(value) {
702
757
  return validDocument(normalized) ? normalized : void 0;
703
758
  }
704
759
  function validDocument(value) {
705
- return record(value) && isDocumentId(value.id) && typeof value.slug === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*(?![\s\S])/u.test(value.slug) && validTitle(value.title) && typeof value.sourcePath === "string" && value.sourcePath.length <= 1024 && /^(?!\/)(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?!.*\/\/)[^\x00-\x1f\x7f\\:]*\.[hH][tT][mM][lL]?(?![\s\S])/u.test(value.sourcePath) && typeof value.sourceContext === "string" && /^(?:personal|git:[a-f0-9]{64})(?![\s\S])/u.test(value.sourceContext) && (value.visibility === "private" || value.visibility === "public") && isRevisionId(value.revisionId) && typeof value.byteLength === "number" && Number.isSafeInteger(value.byteLength) && value.byteLength > 0 && typeof value.createdAt === "number" && Number.isSafeInteger(value.createdAt) && value.createdAt >= 0 && typeof value.url === "string" && new RegExp(`^https://[a-z0-9-]+\\.pageship\\.dev/d/${value.id}-${value.slug}$`, "u").test(value.url);
760
+ return record(value) && isDocumentId(value.id) && typeof value.slug === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*(?![\s\S])/u.test(value.slug) && validTitle(value.title) && typeof value.sourcePath === "string" && value.sourcePath.length <= 1024 && /^(?!\/)(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?!.*\/\/)[^\x00-\x1f\x7f\\:]*\.[hH][tT][mM][lL]?(?![\s\S])/u.test(value.sourcePath) && (value.repository === null || isRepository(value.repository)) && (value.visibility === "private" || value.visibility === "public") && isRevisionId(value.revisionId) && typeof value.byteLength === "number" && Number.isSafeInteger(value.byteLength) && value.byteLength > 0 && typeof value.createdAt === "number" && Number.isSafeInteger(value.createdAt) && value.createdAt >= 0 && typeof value.publishedAt === "number" && Number.isSafeInteger(value.publishedAt) && value.publishedAt >= 0 && typeof value.url === "string" && new RegExp(`^https://[a-z0-9-]+\\.${workspaceDomainPattern}/d/${value.id}-${value.slug}$`, "u").test(value.url);
761
+ }
762
+ function validRevision(value, documentId) {
763
+ return record(value) && isRevisionId(value.id) && typeof value.createdAt === "number" && Number.isSafeInteger(value.createdAt) && value.createdAt >= 0 && typeof value.byteLength === "number" && Number.isSafeInteger(value.byteLength) && value.byteLength > 0 && typeof value.isCurrent === "boolean" && (value.cliVersion === null || isCliVersion(value.cliVersion)) && typeof value.url === "string" && new RegExp(`^https://[a-z0-9-]+\\.${workspaceDomainPattern}/d/${documentId}-[a-z0-9]+(?:-[a-z0-9]+)*/revisions/${value.id}$`, "u").test(value.url);
706
764
  }
707
765
  var DocumentRequestError = class extends Error {
708
766
  status;
@@ -716,7 +774,7 @@ var DocumentRequestError = class extends Error {
716
774
  function failed(response, error) {
717
775
  if (response && (response.status === 401 || response.status === 403)) throw new DocumentRequestError(response.status === 401 ? "signed_out" : "uninvited", response.status === 401 ? "Your installation is not authorized. Run login again." : "This account is not invited.", response.status);
718
776
  if (response?.status === 413) throw new DocumentRequestError("upload_too_large", "HTML exceeds the server's per-file limit.", 413);
719
- throw new Error("Could not complete the document request. Retry with the same idempotency key.", { cause: error });
777
+ throw new Error("Could not complete the document request.", { cause: error });
720
778
  }
721
779
  async function getDocumentLimits(baseUrl, options = {}) {
722
780
  const { data: received, response, error } = await documentsLimits(requestOptions(baseUrl, options));
@@ -730,6 +788,127 @@ async function getDocumentLimits(baseUrl, options = {}) {
730
788
  publicationActions
731
789
  };
732
790
  }
791
+ async function getDocuments(baseUrl, options = {}) {
792
+ const { data: received, response, error } = await documentsList({
793
+ ...requestOptions(baseUrl, options),
794
+ query: { cursor: options.cursor }
795
+ });
796
+ if (!response?.ok) failed(response, error);
797
+ const data = received;
798
+ const documents = record(data) && Array.isArray(data.documents) ? data.documents.map((document) => normalizeDocument(document)) : [];
799
+ if (!record(data) || data.status !== "listed" || !Array.isArray(data.documents) || !documents.every((document) => document !== void 0) || !(data.nextCursor === null || typeof data.nextCursor === "string" && /^[A-Za-z0-9]{6}(?![\s\S])/u.test(data.nextCursor))) throw new Error("Invalid document listing response.");
800
+ return {
801
+ status: "listed",
802
+ documents,
803
+ nextCursor: data.nextCursor
804
+ };
805
+ }
806
+ /** Current metadata of one owned document. Another owner's or a deleted one is `unavailable`. */
807
+ async function getDocument(baseUrl, id, options = {}) {
808
+ const { data: received, response, error } = await documentsGet({
809
+ ...requestOptions(baseUrl, options),
810
+ path: { id }
811
+ });
812
+ if (!response?.ok) {
813
+ if (response?.status === 404) throw new DocumentRequestError("unavailable", "Document unavailable.", 404);
814
+ failed(response, error);
815
+ }
816
+ const data = received;
817
+ const document = record(data) ? normalizeDocument(data.document) : void 0;
818
+ if (record(data) && data.status === "found" && document?.id === id) return document;
819
+ throw new Error("Invalid document response.");
820
+ }
821
+ async function getDocumentRevisions(baseUrl, id, options = {}) {
822
+ const { data: received, response, error } = await documentsListRevisions({
823
+ ...requestOptions(baseUrl, options),
824
+ path: { id },
825
+ query: { cursor: options.cursor }
826
+ });
827
+ if (!response?.ok) {
828
+ if (response?.status === 404) throw new DocumentRequestError("unavailable", "Document unavailable.", 404);
829
+ failed(response, error);
830
+ }
831
+ const data = received;
832
+ if (record(data) && data.status === "listed" && Array.isArray(data.revisions) && data.revisions.every((revision) => validRevision(revision, id)) && (data.nextCursor === null || isRevisionId(data.nextCursor))) return {
833
+ status: "listed",
834
+ revisions: data.revisions,
835
+ nextCursor: data.nextCursor
836
+ };
837
+ throw new Error("Invalid document revision listing response.");
838
+ }
839
+ /** Exact owner-only bytes of the current revision, or of one selected historical revision. */
840
+ async function getDocumentContent(baseUrl, id, options = {}) {
841
+ const request = {
842
+ ...requestOptions(baseUrl, options),
843
+ parseAs: "arrayBuffer"
844
+ };
845
+ const { data: received, response, error } = options.revisionId ? await documentsRevisionContent({
846
+ ...request,
847
+ path: {
848
+ id,
849
+ revisionId: options.revisionId
850
+ }
851
+ }) : await documentsContent({
852
+ ...request,
853
+ path: { id }
854
+ });
855
+ if (!response?.ok) {
856
+ if (response?.status === 404) throw new DocumentRequestError("unavailable", options.revisionId ? "Document or revision unavailable." : "Document unavailable.", 404);
857
+ failed(response, error);
858
+ }
859
+ const data = received;
860
+ if (data instanceof ArrayBuffer && response.headers.get("Content-Type")?.split(";")[0]?.trim().toLowerCase() === "text/html") return new Uint8Array(data);
861
+ throw new Error("Invalid document content response.");
862
+ }
863
+ async function restoreDocumentRevision(baseUrl, id, input, options = {}) {
864
+ const request = requestOptions(baseUrl, options);
865
+ const { data: received, response, error } = await documentsRestore({
866
+ ...request,
867
+ path: { id },
868
+ headers: {
869
+ ...request.headers,
870
+ "idempotency-key": input.idempotencyKey
871
+ },
872
+ body: {
873
+ revisionId: input.revisionId,
874
+ expectedRevisionId: input.expectedRevisionId
875
+ }
876
+ });
877
+ if (!response?.ok) {
878
+ const refusal = error;
879
+ if (response?.status === 400 && record(refusal) && refusal.status === "invalid_restore") throw new DocumentRequestError("invalid_restore", typeof refusal.message === "string" ? refusal.message : "Choose an older revision to restore.", 400);
880
+ if (response?.status === 404) throw new DocumentRequestError("unavailable", "Document or revision unavailable.", 404);
881
+ failed(response, error);
882
+ }
883
+ const data = received;
884
+ const restored = record(data) ? normalizeDocument(data.document) : void 0;
885
+ if (record(data) && (data.status === "restored" || data.status === "revision_conflict") && restored) return {
886
+ status: data.status,
887
+ document: restored
888
+ };
889
+ if (record(data) && data.status === "idempotency_conflict") return { status: "idempotency_conflict" };
890
+ throw new Error("Invalid document restoration response. Retry with the same idempotency key.");
891
+ }
892
+ async function updateDocumentVisibility(baseUrl, id, visibility, options = {}) {
893
+ const { data: received, response, error } = await documentsUpdateVisibility({
894
+ ...requestOptions(baseUrl, options),
895
+ path: { id },
896
+ body: { visibility }
897
+ });
898
+ if (!response?.ok) {
899
+ if (response?.status === 400) throw new DocumentRequestError("invalid_visibility", "Choose private or public.", 400);
900
+ if (response?.status === 401) throw new DocumentRequestError("signed_out", "Sign in to update this document.", 401);
901
+ if (response?.status === 403) throw new DocumentRequestError("uninvited", "Access unavailable.", 403);
902
+ if (response?.status === 404) throw new DocumentRequestError("unavailable", "Document unavailable.", 404);
903
+ throw new Error("Could not update document visibility. Try again.", { cause: error });
904
+ }
905
+ const data = received;
906
+ if (record(data) && data.status === "visibility_updated" && validDocument(data.document) && data.document.visibility === visibility) return {
907
+ status: "visibility_updated",
908
+ document: data.document
909
+ };
910
+ throw new Error("Invalid document visibility response.");
911
+ }
733
912
  async function publishDocument(baseUrl, input, options = {}) {
734
913
  if (input.action) {
735
914
  if (!(await getDocumentLimits(baseUrl, options)).publicationActions.includes(input.action)) throw new DocumentRequestError("publication_action_unsupported", "This server does not support explicit publication actions. Ask the operator to update the server. No HTML was uploaded.", 400);
@@ -744,7 +923,8 @@ async function publishDocument(baseUrl, input, options = {}) {
744
923
  body: new Blob([new Uint8Array(input.bytes)], { type: "text/html" }),
745
924
  query: {
746
925
  sourcePath: input.sourcePath,
747
- sourceContext: input.sourceContext,
926
+ repository: input.repository,
927
+ cliVersion: input.cliVersion,
748
928
  action: input.action,
749
929
  documentId: input.documentId,
750
930
  expectedRevisionId: input.expectedRevisionId
@@ -764,7 +944,7 @@ async function publishDocument(baseUrl, input, options = {}) {
764
944
  candidates
765
945
  };
766
946
  }
767
- if (record(data) && (data.status === "idempotency_conflict" || data.status === "address_required" || data.status === "unavailable")) return { status: data.status };
947
+ if (record(data) && (data.status === "idempotency_conflict" || data.status === "address_required" || data.status === "unavailable" || data.status === "document_id_taken")) return { status: data.status };
768
948
  throw new Error("Invalid publication response. Retry with the same idempotency key.");
769
949
  }
770
950
  //#endregion
@@ -782,7 +962,7 @@ async function getWorkspace(baseUrl, options = {}) {
782
962
  });
783
963
  if (!response?.ok) throw new Error("Could not load your workspace.", { cause: error });
784
964
  if (data?.status === "signed_out" || data?.status === "uninvited") return { status: data.status };
785
- if (data?.status === "admitted" && typeof data.user?.id === "string" && data.user.id.length > 0 && typeof data.user?.name === "string" && typeof data.workspace?.id === "string" && data.workspace.id.length > 0) return data;
965
+ if (data?.status === "admitted" && typeof data.user?.id === "string" && data.user.id.length > 0 && typeof data.user?.name === "string" && (data.user.login === void 0 || typeof data.user.login === "string") && (data.user.githubAccountId === void 0 || typeof data.user.githubAccountId === "string" && /^[1-9]\d*$/u.test(data.user.githubAccountId)) && typeof data.workspace?.id === "string" && data.workspace.id.length > 0 && (data.address === void 0 || data.address === null || isWorkspaceAddress(data.address))) return data;
786
966
  throw new Error("Invalid workspace response.");
787
967
  }
788
968
  function validateCompatibility(value) {
@@ -830,12 +1010,50 @@ async function checkCompatibility(baseUrl, options = {}) {
830
1010
  }
831
1011
  //#endregion
832
1012
  //#region src/credential-store.ts
1013
+ /** Write a private 0600 file through a synced temporary file, so a crash cannot leave it half written. */
1014
+ async function replaceFile(path, contents) {
1015
+ const temporary = `${path}.${randomUUID()}.tmp`;
1016
+ try {
1017
+ const file = await open(temporary, "wx", 384);
1018
+ try {
1019
+ await file.writeFile(contents);
1020
+ await file.sync();
1021
+ } finally {
1022
+ await file.close();
1023
+ }
1024
+ await rename(temporary, path);
1025
+ } finally {
1026
+ await rm(temporary, { force: true });
1027
+ }
1028
+ }
1029
+ /** The CLI configuration directory that holds credentials. */
1030
+ const configDirectory = () => process.env.PAGESHIP_CONFIG_DIR ?? join((process.platform === "win32" ? process.env.LOCALAPPDATA : process.env.XDG_CONFIG_HOME) ?? join(homedir(), ".config"), "pageship");
1031
+ /**
1032
+ * Read one origin's credentials without creating the configuration directory, its lock
1033
+ * or any file. A missing directory has no credentials.
1034
+ */
1035
+ async function inspectCredentials(server) {
1036
+ const directory = configDirectory();
1037
+ if (await lstat(directory).then(() => false, (error) => {
1038
+ if (error.code === "ENOENT") return true;
1039
+ throw error;
1040
+ }) && isAbsolute(directory)) return {};
1041
+ const store = await credentialStore(server, true, { create: false });
1042
+ try {
1043
+ await access(directory, constants.R_OK | constants.X_OK).catch(() => {
1044
+ throw new Error("You do not have permission to read it.");
1045
+ });
1046
+ return await store.read();
1047
+ } finally {
1048
+ await store.close();
1049
+ }
1050
+ }
833
1051
  /** Separate files bind secrets to an exact server origin, outside project directories. */
834
- async function credentialStore(server, readOnly = false) {
1052
+ async function credentialStore(server, readOnly = false, { create = true } = {}) {
835
1053
  const windows = process.platform === "win32";
836
- const directory = process.env.PAGESHIP_CONFIG_DIR ?? join((windows ? process.env.LOCALAPPDATA : process.env.XDG_CONFIG_HOME) ?? join(homedir(), ".config"), "pageship");
1054
+ const directory = configDirectory();
837
1055
  if (!isAbsolute(directory)) throw new Error("PAGESHIP_CONFIG_DIR must be an absolute private directory.");
838
- await mkdir(directory, {
1056
+ if (create) await mkdir(directory, {
839
1057
  recursive: true,
840
1058
  mode: 448
841
1059
  });
@@ -850,6 +1068,9 @@ async function credentialStore(server, readOnly = false) {
850
1068
  throw new Error(`Another authorization command may be running. Retry after it exits. If it was killed, remove ${lock} after checking no Pageship process is running.`);
851
1069
  }
852
1070
  return {
1071
+ /** The verified private directory and this origin's file stem, shared with the publish manifest. */
1072
+ directory,
1073
+ name,
853
1074
  async read() {
854
1075
  if (windows) try {
855
1076
  if (!(await lstat(path)).isFile()) throw new Error("Credential file must not be a link.");
@@ -885,19 +1106,7 @@ async function credentialStore(server, readOnly = false) {
885
1106
  },
886
1107
  async write(value) {
887
1108
  if (readOnly) throw new Error("Cannot update credentials through a read-only store.");
888
- const temporary = join(directory, `${name}.${randomUUID()}.tmp`);
889
- try {
890
- const file = await open(temporary, "wx", 384);
891
- try {
892
- await file.writeFile(JSON.stringify(value));
893
- await file.sync();
894
- } finally {
895
- await file.close();
896
- }
897
- await rename(temporary, path);
898
- } finally {
899
- await rm(temporary, { force: true });
900
- }
1109
+ await replaceFile(path, JSON.stringify(value));
901
1110
  },
902
1111
  close: async () => {
903
1112
  if (!readOnly) await rm(lock, {
@@ -908,7 +1117,267 @@ async function credentialStore(server, readOnly = false) {
908
1117
  };
909
1118
  }
910
1119
  //#endregion
911
- //#region src/authorization.ts
1120
+ //#region src/document-marker.ts
1121
+ const prefix = "<!-- pageship:v1 document=";
1122
+ const trailing = /<!-- pageship:v1 document=([A-Za-z0-9]{6}) -->[\t\n\f\r ]*$/u;
1123
+ const text = (bytes) => bytes.toString("latin1");
1124
+ /**
1125
+ * Whether the file starts with a UTF-16 byte-order mark, in either byte order. The ASCII
1126
+ * marker would corrupt such a file's encoding, so it gets none.
1127
+ */
1128
+ const isUtf16 = (bytes) => bytes[0] === 255 && bytes[1] === 254 || bytes[0] === 254 && bytes[1] === 255;
1129
+ /** The document ID named by the file's marker, if it ends with one. */
1130
+ function readMarker(bytes) {
1131
+ const id = trailing.exec(text(bytes))?.[1];
1132
+ return isDocumentId(id) ? id : void 0;
1133
+ }
1134
+ /**
1135
+ * Points the marker at `id` without changing any other byte. An existing marker keeps
1136
+ * everything but its ID. A new marker follows the file's final line ending; a file
1137
+ * without one gets the marker on a new last line and still has no final line ending.
1138
+ */
1139
+ function withMarker(bytes, id) {
1140
+ const source = text(bytes);
1141
+ const match = trailing.exec(source);
1142
+ if (match) {
1143
+ const start = match.index + 26;
1144
+ return Buffer.concat([
1145
+ bytes.subarray(0, start),
1146
+ Buffer.from(id, "latin1"),
1147
+ bytes.subarray(start + id.length)
1148
+ ]);
1149
+ }
1150
+ const marker = `${prefix}${id} -->`;
1151
+ const ending = source.endsWith("\r\n") ? "\r\n" : source.endsWith("\n") ? "\n" : void 0;
1152
+ const added = ending ? `${marker}${ending}` : `${source.includes("\r\n") ? "\r\n" : "\n"}${marker}`;
1153
+ return Buffer.concat([bytes, Buffer.from(added, "latin1")]);
1154
+ }
1155
+ /**
1156
+ * The ID a new document proposes. It follows from the idempotency key, so a retry with
1157
+ * the same key proposes the same ID and uploads the same marked bytes.
1158
+ */
1159
+ function proposedDocumentId(idempotencyKey) {
1160
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1161
+ let id = "";
1162
+ for (let round = 0; id.length < 6; round++) for (const byte of createHash("sha256").update(`${round}:${idempotencyKey}`).digest()) if (byte < 248 && id.length < 6) id += alphabet[byte % 62];
1163
+ if (!isDocumentId(id)) throw new Error("Could not derive a document ID.");
1164
+ return id;
1165
+ }
1166
+ /**
1167
+ * Writes the marked bytes over the local file in place, keeping its inode and mode.
1168
+ * Only the bytes after the first difference are written, and only while the file still
1169
+ * holds exactly the bytes that were published.
1170
+ */
1171
+ async function writeMarker(path, original, marked) {
1172
+ const file = await open(path, "r+");
1173
+ try {
1174
+ const current = Buffer.alloc(original.length + 1);
1175
+ let length = 0;
1176
+ for (;;) {
1177
+ const { bytesRead } = await file.read(current, length, current.length - length, length);
1178
+ if (!bytesRead || (length += bytesRead) === current.length) break;
1179
+ }
1180
+ if (length !== original.length || !current.subarray(0, length).equals(original)) throw new Error("The file changed while it was being published.");
1181
+ let offset = 0;
1182
+ while (offset < original.length && original[offset] === marked[offset]) offset++;
1183
+ while (offset < marked.length) {
1184
+ const { bytesWritten } = await file.write(marked, offset, marked.length - offset, offset);
1185
+ offset += bytesWritten;
1186
+ }
1187
+ } finally {
1188
+ await file.close();
1189
+ }
1190
+ }
1191
+ //#endregion
1192
+ //#region src/publish-manifest.ts
1193
+ const Time = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
1194
+ const Entry$1 = Schema.Struct({
1195
+ documentId: Schema.String.pipe(Schema.refine(isDocumentId)),
1196
+ revisionId: Schema.String.pipe(Schema.refine(isRevisionId)),
1197
+ /** When the recorded publication started, from its idempotency key. Older entries omit it. */
1198
+ startedAt: Schema.optionalKey(Time)
1199
+ });
1200
+ /** Maps a file's real path to its document and the last revision published from this machine. */
1201
+ const Manifest = Schema.fromJsonString(Schema.Record(Schema.String, Entry$1));
1202
+ const decode = Schema.decodeUnknownOption(Manifest);
1203
+ const LockOwner = Schema.fromJsonString(Schema.Struct({
1204
+ pid: Schema.Int,
1205
+ acquiredAt: Time
1206
+ }));
1207
+ const decodeOwner = Schema.decodeUnknownOption(LockOwner);
1208
+ /**
1209
+ * A lock is stale once its process has exited or it is older than this. The lock covers only
1210
+ * one small read and write, so a live holder releases it long before.
1211
+ */
1212
+ const staleLockMs = 1e4;
1213
+ /** A UUIDv7 idempotency key, whose first 48 bits hold its creation time in milliseconds. */
1214
+ function publicationKey(now = Date.now()) {
1215
+ const hex = `${now.toString(16).padStart(12, "0")}7${randomUUID().replaceAll("-", "").slice(13)}`;
1216
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1217
+ }
1218
+ /** When a UUIDv7 key was made, no later than now. Other keys count as made now. */
1219
+ const keyTime = (key) => {
1220
+ const time = /^([0-9a-f]{8})-([0-9a-f]{4})-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.exec(key);
1221
+ return Math.min(time ? Number.parseInt(time[1] + time[2], 16) : Infinity, Date.now());
1222
+ };
1223
+ const running = (pid) => {
1224
+ try {
1225
+ process.kill(pid, 0);
1226
+ return true;
1227
+ } catch (error) {
1228
+ return error.code === "EPERM";
1229
+ }
1230
+ };
1231
+ /** The stale lock's contents, or undefined while it is held or already gone. */
1232
+ async function stale(lock) {
1233
+ const [raw, info] = await Promise.all([readFile(lock, "utf8"), stat(lock)]).catch(() => []);
1234
+ if (raw === void 0 || !info) return;
1235
+ const owner = Option.getOrUndefined(decodeOwner(raw));
1236
+ return Date.now() - (owner?.acquiredAt ?? info.mtimeMs) > 1e4 || owner !== void 0 && !running(owner.pid) ? raw : void 0;
1237
+ }
1238
+ /** Remove the lock only while it still holds these contents, so another holder's lock stays. */
1239
+ async function release(lock, contents) {
1240
+ if (await readFile(lock, "utf8").catch(() => void 0) === contents) await rm(lock, { force: true });
1241
+ }
1242
+ /** Run with an exclusive-create lock file holding this process's ID and the time it was taken. */
1243
+ async function locked(lock, run) {
1244
+ const deadline = Date.now() + 2 * staleLockMs;
1245
+ const token = randomUUID();
1246
+ let contents;
1247
+ for (;;) {
1248
+ contents = JSON.stringify({
1249
+ pid: process.pid,
1250
+ acquiredAt: Date.now(),
1251
+ token
1252
+ });
1253
+ try {
1254
+ await writeFile(lock, contents, {
1255
+ flag: "wx",
1256
+ mode: 384
1257
+ });
1258
+ break;
1259
+ } catch (error) {
1260
+ const { code } = error;
1261
+ if (code !== "EEXIST" && !(process.platform === "win32" && code === "EPERM")) throw error;
1262
+ }
1263
+ const abandoned = await stale(lock);
1264
+ if (abandoned !== void 0) await release(lock, abandoned);
1265
+ else if (Date.now() > deadline) throw new Error(`Another publish kept ${lock} for too long. Publish again.`);
1266
+ else await setTimeout$1(20);
1267
+ }
1268
+ try {
1269
+ return await run();
1270
+ } finally {
1271
+ await release(lock, contents);
1272
+ }
1273
+ }
1274
+ /**
1275
+ * The publish manifest for one server origin, stored next to that origin's credentials.
1276
+ * An unreadable manifest counts as empty and is replaced by the next successful publish.
1277
+ */
1278
+ function publishManifest(store) {
1279
+ const path = join(store.directory, `${store.name}.manifest.json`);
1280
+ const lock = join(store.directory, `${store.name}.manifest.lock`);
1281
+ const read = async () => {
1282
+ const raw = await readFile(path, "utf8").catch(() => void 0);
1283
+ return raw === void 0 ? {} : Option.getOrElse(decode(raw), () => ({}));
1284
+ };
1285
+ return {
1286
+ find: async (file) => (await read())[file],
1287
+ /**
1288
+ * Under the lock, reread to keep other entries, then replace the file atomically. A
1289
+ * publication that started before the file's entry, such as a replayed key, leaves it
1290
+ * alone. Returns whether the entry now names this publication.
1291
+ */
1292
+ record: (file, entry) => locked(lock, async () => {
1293
+ const manifest = await read();
1294
+ const current = manifest[file];
1295
+ if (current?.documentId === entry.documentId && current.revisionId === entry.revisionId) return true;
1296
+ if ((current?.startedAt ?? -1) > entry.startedAt) return false;
1297
+ await replaceFile(path, JSON.stringify({
1298
+ ...manifest,
1299
+ [file]: entry
1300
+ }));
1301
+ return true;
1302
+ })
1303
+ };
1304
+ }
1305
+ //#endregion
1306
+ //#region src/result.ts
1307
+ /** Every status a command returns, with its outcome. A new status must be classified here. */
1308
+ const outcomes = {
1309
+ help: "succeeded",
1310
+ version: "succeeded",
1311
+ admitted: "succeeded",
1312
+ authorized: "succeeded",
1313
+ published: "succeeded",
1314
+ listed: "succeeded",
1315
+ downloaded: "succeeded",
1316
+ visibility_updated: "succeeded",
1317
+ restored: "succeeded",
1318
+ skills_installed: "succeeded",
1319
+ authorization_pending: "pending",
1320
+ expired: "pending",
1321
+ signed_out: "pending",
1322
+ reauthentication_required: "pending",
1323
+ choice_required: "pending",
1324
+ address_required: "pending",
1325
+ source_collision: "pending",
1326
+ repository_mismatch: "pending",
1327
+ marker_unavailable: "pending",
1328
+ revision_conflict: "pending",
1329
+ cancelled: "pending",
1330
+ usage_error: "failed",
1331
+ internal_error: "failed",
1332
+ request_failed: "failed",
1333
+ interrupted: "failed",
1334
+ unreachable: "failed",
1335
+ client_too_old: "failed",
1336
+ server_too_old: "failed",
1337
+ unsupported: "failed",
1338
+ configuration_unreadable: "failed",
1339
+ uninvited: "failed",
1340
+ denied: "failed",
1341
+ invalid_attempt: "failed",
1342
+ invalid_action: "failed",
1343
+ invalid_file: "failed",
1344
+ invalid_output: "failed",
1345
+ invalid_restore: "failed",
1346
+ output_exists: "failed",
1347
+ upload_too_large: "failed",
1348
+ unavailable: "failed",
1349
+ idempotency_conflict: "failed",
1350
+ document_id_taken: "failed",
1351
+ publication_action_unsupported: "failed",
1352
+ skills_modified: "failed"
1353
+ };
1354
+ const isStatus = (value) => Object.hasOwn(outcomes, value);
1355
+ const commandNames = [
1356
+ "status",
1357
+ "login",
1358
+ "whoami",
1359
+ "publish",
1360
+ "list",
1361
+ "history",
1362
+ "download",
1363
+ "visibility",
1364
+ "restore",
1365
+ "skills"
1366
+ ];
1367
+ const isCommandName = (value) => commandNames.some((name) => name === value);
1368
+ /** Exit codes follow the outcome, except an interruption, which exits 130. */
1369
+ function exitCode(status) {
1370
+ if (status === "interrupted") return 130;
1371
+ const outcome = outcomes[status];
1372
+ return outcome === "succeeded" ? 0 : outcome === "pending" ? 2 : 1;
1373
+ }
1374
+ //#endregion
1375
+ //#region src/server.ts
1376
+ /**
1377
+ * Hosted Pageship API origin. Keep in sync with `apiOrigin` in the repository's
1378
+ * `DEPLOYMENT_CONFIG` variable used by application deployment.
1379
+ */
1380
+ const hostedServer = "https://api.pageship.dev";
912
1381
  function serverOrigin(value) {
913
1382
  const url = new URL(value);
914
1383
  if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || !(url.protocol === "https:" || url.protocol === "http:" && [
@@ -918,258 +1387,102 @@ function serverOrigin(value) {
918
1387
  ].includes(url.hostname))) throw new Error("Authorization requires an HTTPS server origin, or HTTP on localhost for local development. Do not include a path, credentials, query or fragment.");
919
1388
  return url.origin;
920
1389
  }
921
- async function authRequest(server, path, body, signal) {
922
- const response = await fetch(`${server}/api/auth${path}`, {
923
- method: "POST",
924
- headers: { "Content-Type": "application/json" },
925
- body: JSON.stringify(body),
926
- redirect: "error",
927
- credentials: "omit",
928
- cache: "no-store",
929
- signal: AbortSignal.any([signal, AbortSignal.timeout(1e4)])
930
- });
931
- if (response.status >= 500) throw new Error(`Authorization server returned HTTP ${response.status}. Retry later with login --complete if you have a pending code.`);
932
- let value;
1390
+ /** An explicit `--server` overrides the hosted service and is repeated in every follow-up. */
1391
+ function selectServer(value) {
1392
+ if (value === void 0) return {
1393
+ origin: hostedServer,
1394
+ args: []
1395
+ };
1396
+ const origin = serverOrigin(value);
1397
+ return {
1398
+ origin,
1399
+ args: ["--server", origin]
1400
+ };
1401
+ }
1402
+ /**
1403
+ * Display a follow-up command for a POSIX shell. Values such as `[::1]` origins are
1404
+ * single-quoted, so `$`, backticks and quotes in an origin stay literal.
1405
+ */
1406
+ function commandLine(command, args) {
1407
+ return [
1408
+ "pageship",
1409
+ command,
1410
+ ...args
1411
+ ].map((part) => /^[\w./:=@%+,-]+$/u.test(part) ? part : `'${part.replaceAll("'", "'\\''")}'`).join(" ");
1412
+ }
1413
+ //#endregion
1414
+ //#region package.json
1415
+ var version = "0.3.0";
1416
+ //#endregion
1417
+ //#region src/publish.ts
1418
+ const execute = promisify(execFile);
1419
+ const contained = (root, path) => {
1420
+ const part = relative(root, path);
1421
+ return part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part);
1422
+ };
1423
+ async function sourceMetadata(file) {
1424
+ const path = await realpath(resolve(file));
1425
+ const cwd = await realpath(process.cwd());
1426
+ let root = contained(cwd, path) ? cwd : dirname(path);
1427
+ let repository;
933
1428
  try {
934
- value = await response.json();
935
- } catch {
936
- throw new Error(`Authorization server returned an unreadable response (HTTP ${response.status}). Check the server and retry.`);
1429
+ const git = async (...args) => (await execute("git", [
1430
+ "-C",
1431
+ dirname(path),
1432
+ ...args
1433
+ ], {
1434
+ timeout: 5e3,
1435
+ maxBuffer: 16384
1436
+ })).stdout.trim();
1437
+ const gitRoot = await realpath(await git("rev-parse", "--show-toplevel"));
1438
+ if (contained(gitRoot, path)) {
1439
+ root = gitRoot;
1440
+ const remote = await git("remote", "get-url", "origin").catch(() => "");
1441
+ const url = remote.includes("://") ? new URL(remote) : /^[^/]+@[^:]+:.+/u.test(remote) ? new URL(`ssh://${remote.replace(":", "/")}`) : void 0;
1442
+ if (url && [
1443
+ "https:",
1444
+ "http:",
1445
+ "ssh:",
1446
+ "git:"
1447
+ ].includes(url.protocol) && url.hostname) {
1448
+ const normalized = `${url.hostname.toLowerCase()}${url.port ? `:${url.port}` : ""}/${url.pathname.replace(/^\/+|\/+$/gu, "").replace(/\.git$/u, "")}`;
1449
+ if (isRepository(normalized)) repository = normalized;
1450
+ }
1451
+ }
1452
+ } catch {}
1453
+ const sourcePath = relative(root, path).split(sep).join("/");
1454
+ if (!/\.html?$/iu.test(sourcePath)) throw new Error("Publish one .html or .htm file.");
1455
+ return {
1456
+ path,
1457
+ sourcePath,
1458
+ repository
1459
+ };
1460
+ }
1461
+ async function fileBytes(path, limit) {
1462
+ const file = await open(path, "r");
1463
+ try {
1464
+ const stat = await file.stat();
1465
+ if (!stat.isFile()) throw new Error("Publish one regular HTML file.");
1466
+ if (stat.size > limit) return;
1467
+ if (stat.size === 0) throw new Error("The HTML file is empty.");
1468
+ const chunks = [];
1469
+ let length = 0;
1470
+ while (length <= limit) {
1471
+ const chunk = Buffer.alloc(Math.min(65536, limit + 1 - length));
1472
+ const { bytesRead } = await file.read(chunk);
1473
+ if (!bytesRead) break;
1474
+ chunks.push(chunk.subarray(0, bytesRead));
1475
+ length += bytesRead;
1476
+ }
1477
+ return length > limit ? void 0 : Buffer.concat(chunks);
1478
+ } finally {
1479
+ await file.close();
937
1480
  }
938
- if (!value || typeof value !== "object") throw new Error("Invalid authorization response. Check the server and retry.");
939
- const data = value;
940
- if (!response.ok && typeof data.error !== "string") throw new Error(`Authorization server returned HTTP ${response.status}. Retry later.`);
941
- return data;
942
1481
  }
943
- function pendingResult(pending, server) {
1482
+ function invalidFile(error) {
944
1483
  return {
945
- status: "authorization_pending",
946
- message: "Approve this installation in your browser, then run the completion command.",
947
- verificationUri: pending.verificationUri,
948
- verificationUriComplete: pending.verificationUriComplete,
949
- userCode: pending.userCode,
950
- expiresAt: new Date(pending.expiresAt).toISOString(),
951
- retryAfterSeconds: Math.max(0, Math.ceil((pending.nextPollAt - Date.now()) / 1e3)),
952
- next: {
953
- command: "login",
954
- args: [
955
- "--server",
956
- server,
957
- "--complete"
958
- ]
959
- }
960
- };
961
- }
962
- async function authorize(options) {
963
- const server = serverOrigin(options.server);
964
- const compatibility = await checkCompatibility(server, { signal: options.signal });
965
- if (compatibility.status !== "compatible") return compatibility;
966
- const store = await credentialStore(server);
967
- try {
968
- const credentials = await store.read();
969
- if (credentials.token && !options.restart && !credentials.pending) {
970
- const access = await getWorkspace(server, {
971
- token: credentials.token,
972
- signal: options.signal
973
- });
974
- if (access.status === "admitted") return {
975
- ...access,
976
- status: "authorized",
977
- message: `Authorized as ${access.user.name}.`
978
- };
979
- if (access.status === "uninvited") return {
980
- status: "uninvited",
981
- message: "This account is no longer invited. Contact the server owner or use login --restart for another account."
982
- };
983
- delete credentials.token;
984
- await store.write(credentials);
985
- }
986
- if (options.restart) delete credentials.pending;
987
- if (!credentials.pending) {
988
- if (options.complete) return {
989
- status: "invalid_attempt",
990
- message: "No pending authorization. Run login without --complete to start a new attempt."
991
- };
992
- const data = await authRequest(server, "/device/code", { client_id: "pageship-cli" }, options.signal);
993
- if (typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.verification_uri_complete !== "string" || typeof data.expires_in !== "number" || data.expires_in <= 0 || typeof data.interval !== "number" || data.interval < 1) throw new Error("Invalid device authorization response. Check the server and retry.");
994
- for (const uri of [data.verification_uri, data.verification_uri_complete]) {
995
- const parsed = new URL(uri);
996
- if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.protocol === "http:" && new URL(server).protocol !== "http:") throw new Error("Invalid browser verification URL.");
997
- }
998
- credentials.pending = {
999
- deviceCode: data.device_code,
1000
- userCode: data.user_code,
1001
- verificationUri: data.verification_uri,
1002
- verificationUriComplete: data.verification_uri_complete,
1003
- expiresAt: Date.now() + data.expires_in * 1e3,
1004
- interval: data.interval,
1005
- nextPollAt: 0
1006
- };
1007
- await store.write(credentials);
1008
- }
1009
- const pending = credentials.pending;
1010
- if (options.interactive) options.progress(`Open ${pending.verificationUriComplete}\nCheck code ${pending.userCode}. Only approve an installation you started.\nWaiting for approval. Press Ctrl+C to stop; you can resume with login --complete.`);
1011
- do {
1012
- if (options.signal.aborted) return {
1013
- status: "interrupted",
1014
- message: "Authorization interrupted. Run login --complete to resume, or login --restart for a new code."
1015
- };
1016
- if (Date.now() >= pending.expiresAt) {
1017
- delete credentials.pending;
1018
- await store.write(credentials);
1019
- return {
1020
- status: "expired",
1021
- message: "The authorization code expired. Run login again for a new code."
1022
- };
1023
- }
1024
- if (!options.interactive && (!options.complete || Date.now() < pending.nextPollAt)) return pendingResult(pending, server);
1025
- if (Date.now() < pending.nextPollAt) await setTimeout$1(pending.nextPollAt - Date.now(), void 0, { signal: options.signal });
1026
- pending.nextPollAt = Date.now() + pending.interval * 1e3;
1027
- await store.write(credentials);
1028
- const data = await authRequest(server, "/device/token", {
1029
- client_id: "pageship-cli",
1030
- device_code: pending.deviceCode,
1031
- grant_type: "urn:ietf:params:oauth:grant-type:device_code"
1032
- }, options.signal);
1033
- if (typeof data.access_token === "string" && data.access_token.length > 0) {
1034
- credentials.token = data.access_token;
1035
- delete credentials.pending;
1036
- await store.write(credentials);
1037
- const access = await getWorkspace(server, {
1038
- token: credentials.token,
1039
- signal: options.signal
1040
- });
1041
- if (access.status !== "admitted") return {
1042
- status: access.status,
1043
- message: "Authorization finished, but workspace access is unavailable. Check your admission with the server owner."
1044
- };
1045
- return {
1046
- ...access,
1047
- status: "authorized",
1048
- message: `Authorized as ${access.user.name}. Your credential is stored privately for this server.`
1049
- };
1050
- }
1051
- if (data.error === "slow_down") {
1052
- pending.interval += 5;
1053
- pending.nextPollAt = Date.now() + pending.interval * 1e3;
1054
- await store.write(credentials);
1055
- } else if (data.error !== "authorization_pending") {
1056
- delete credentials.pending;
1057
- await store.write(credentials);
1058
- const status = data.error === "access_denied" ? "denied" : data.error === "expired_token" ? "expired" : "invalid_attempt";
1059
- return {
1060
- status,
1061
- message: `Authorization ${status.replaceAll("_", " ")}. Run login again for a new code.`
1062
- };
1063
- }
1064
- if (!options.interactive) return pendingResult(pending, server);
1065
- } while (!options.signal.aborted);
1066
- return {
1067
- status: "interrupted",
1068
- message: "Authorization interrupted. Run login --complete to resume."
1069
- };
1070
- } catch (error) {
1071
- if (options.signal.aborted) return {
1072
- status: "interrupted",
1073
- message: "Authorization interrupted. Run login --complete to resume or login --restart to start again."
1074
- };
1075
- throw error;
1076
- } finally {
1077
- await store.close();
1078
- }
1079
- }
1080
- async function identify(serverValue, signal) {
1081
- const server = serverOrigin(serverValue);
1082
- const compatibility = await checkCompatibility(server, { signal });
1083
- if (compatibility.status !== "compatible") return compatibility;
1084
- const store = await credentialStore(server, true);
1085
- try {
1086
- const { token } = await store.read();
1087
- if (!token) return {
1088
- status: "signed_out",
1089
- message: "This installation is not authorized. Run login --server with this server to begin."
1090
- };
1091
- const access = await getWorkspace(server, {
1092
- token,
1093
- signal
1094
- });
1095
- return {
1096
- ...access,
1097
- message: access.status === "admitted" ? `Signed in as ${access.user.name}. Personal workspace: ${access.workspace.id}${access.installation ? `\nCLI installation: ${access.installation.id}` : ""}` : access.status === "uninvited" ? "This account is not invited. Contact the server owner." : "Your credential was revoked, expired or is invalid. Run login --server with this server to sign in again."
1098
- };
1099
- } finally {
1100
- await store.close();
1101
- }
1102
- }
1103
- //#endregion
1104
- //#region src/publish.ts
1105
- const execute = promisify(execFile);
1106
- const contained = (root, path) => {
1107
- const part = relative(root, path);
1108
- return part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part);
1109
- };
1110
- async function sourceMetadata(file) {
1111
- const path = await realpath(resolve(file));
1112
- const cwd = await realpath(process.cwd());
1113
- let root = contained(cwd, path) ? cwd : dirname(path);
1114
- let sourceContext = "personal";
1115
- try {
1116
- const git = async (...args) => (await execute("git", [
1117
- "-C",
1118
- dirname(path),
1119
- ...args
1120
- ], {
1121
- timeout: 5e3,
1122
- maxBuffer: 16384
1123
- })).stdout.trim();
1124
- const gitRoot = await realpath(await git("rev-parse", "--show-toplevel"));
1125
- if (contained(gitRoot, path)) {
1126
- root = gitRoot;
1127
- const remote = await git("remote", "get-url", "origin").catch(() => "");
1128
- const url = remote.includes("://") ? new URL(remote) : /^[^/]+@[^:]+:.+/u.test(remote) ? new URL(`ssh://${remote.replace(":", "/")}`) : void 0;
1129
- if (url && [
1130
- "https:",
1131
- "http:",
1132
- "ssh:",
1133
- "git:"
1134
- ].includes(url.protocol) && url.hostname) {
1135
- const repository = `${url.hostname.toLowerCase()}${url.port ? `:${url.port}` : ""}/${url.pathname.replace(/^\/+|\/+$/gu, "").replace(/\.git$/u, "")}`;
1136
- sourceContext = `git:${createHash("sha256").update(repository).digest("hex")}`;
1137
- }
1138
- }
1139
- } catch {}
1140
- const sourcePath = relative(root, path).split(sep).join("/");
1141
- if (!/\.html?$/iu.test(sourcePath)) throw new Error("Publish one .html or .htm file.");
1142
- return {
1143
- path,
1144
- sourcePath,
1145
- sourceContext
1146
- };
1147
- }
1148
- async function fileBytes(path, limit) {
1149
- const file = await open(path, "r");
1150
- try {
1151
- const stat = await file.stat();
1152
- if (!stat.isFile()) throw new Error("Publish one regular HTML file.");
1153
- if (stat.size > limit) return;
1154
- if (stat.size === 0) throw new Error("The HTML file is empty.");
1155
- const chunks = [];
1156
- let length = 0;
1157
- while (length <= limit) {
1158
- const chunk = Buffer.alloc(Math.min(65536, limit + 1 - length));
1159
- const { bytesRead } = await file.read(chunk);
1160
- if (!bytesRead) break;
1161
- chunks.push(chunk.subarray(0, bytesRead));
1162
- length += bytesRead;
1163
- }
1164
- return length > limit ? void 0 : Buffer.concat(chunks);
1165
- } finally {
1166
- await file.close();
1167
- }
1168
- }
1169
- function invalidFile(error) {
1170
- return {
1171
- status: "invalid_file",
1172
- message: `Could not read the HTML file. ${error instanceof Error ? error.message : "Check the file and try again."}`
1484
+ status: "invalid_file",
1485
+ message: `Could not read the HTML file. ${error instanceof Error ? error.message : "Check the file and try again."}`
1173
1486
  };
1174
1487
  }
1175
1488
  function actionArgs(action) {
@@ -1180,13 +1493,10 @@ function actionArgs(action) {
1180
1493
  action.expectedRevisionId
1181
1494
  ] : [];
1182
1495
  }
1183
- function resolutions(file, server, candidates) {
1496
+ /** `contextArgs` are the server and marker arguments every follow-up keeps. */
1497
+ function resolutions(file, contextArgs, candidates) {
1184
1498
  const command = "publish";
1185
- const args = [
1186
- file,
1187
- "--server",
1188
- server
1189
- ];
1499
+ const args = [file, ...contextArgs];
1190
1500
  return {
1191
1501
  new: {
1192
1502
  command,
@@ -1251,8 +1561,26 @@ async function publish(options) {
1251
1561
  documentId: options.revise,
1252
1562
  expectedRevisionId: options.expectedRevision
1253
1563
  } : {};
1254
- const server = serverOrigin(options.server);
1255
- const idempotencyKey = options.idempotencyKey ?? randomUUID();
1564
+ const selection = selectServer(options.server);
1565
+ const server = selection.origin;
1566
+ const contextArgs = [...selection.args, ...options.noMarker ? ["--no-marker"] : []];
1567
+ const login = {
1568
+ command: "login",
1569
+ args: selection.args
1570
+ };
1571
+ if (options.addressChoice !== void 0 || options.refreshAddresses) {
1572
+ const args = [...selection.args, ...options.addressChoice !== void 0 ? ["--address-choice", options.addressChoice] : ["--refresh-addresses"]];
1573
+ return {
1574
+ status: "invalid_action",
1575
+ message: `Workspace address selection moved to login. Run ${commandLine("login", args)}, then publish again. No HTML was uploaded.`,
1576
+ next: {
1577
+ command: "login",
1578
+ args
1579
+ }
1580
+ };
1581
+ }
1582
+ let idempotencyKey = options.idempotencyKey ?? publicationKey();
1583
+ let started = false;
1256
1584
  let source;
1257
1585
  try {
1258
1586
  source = await sourceMetadata(options.file);
@@ -1260,13 +1588,17 @@ async function publish(options) {
1260
1588
  return invalidFile(error);
1261
1589
  }
1262
1590
  const compatibility = await checkCompatibility(server, { signal: options.signal });
1263
- if (compatibility.status !== "compatible") return compatibility;
1591
+ if (compatibility.status !== "compatible") return {
1592
+ ...compatibility,
1593
+ status: compatibility.status
1594
+ };
1264
1595
  const store = await credentialStore(server, true);
1265
1596
  try {
1266
1597
  const { token } = await store.read();
1267
1598
  if (!token) return {
1268
1599
  status: "signed_out",
1269
- message: "This installation is not authorized. Run login first."
1600
+ message: `This installation is not authorized. Run ${commandLine("login", selection.args)} first.`,
1601
+ next: login
1270
1602
  };
1271
1603
  const request = {
1272
1604
  token,
@@ -1284,197 +1616,1198 @@ async function publish(options) {
1284
1616
  maxUploadBytes,
1285
1617
  message: `The HTML file exceeds the ${maxUploadBytes}-byte limit. No HTML was uploaded.`
1286
1618
  };
1287
- let address = await updateWorkspaceAddress(server, options.addressChoice ? {
1288
- action: "select",
1289
- choiceId: options.addressChoice
1290
- } : { action: options.refreshAddresses ? "refresh" : "allocate" }, request);
1291
- while (address.status === "choice_required" && options.interactive) {
1292
- console.log("Choose your workspace address:");
1293
- address.choices.forEach((choice, index) => console.log(`${index + 1}. ${choice.hostname}`));
1294
- const input = createInterface({
1295
- input: process.stdin,
1296
- output: process.stdout
1619
+ const utf16 = !options.noMarker && isUtf16(bytes);
1620
+ const skipMarker = options.noMarker || utf16;
1621
+ if (!skipMarker && withMarker(bytes, "AAAAAA").length > maxUploadBytes) return {
1622
+ status: "upload_too_large",
1623
+ maxUploadBytes,
1624
+ message: `With its document marker, the HTML file exceeds the ${maxUploadBytes}-byte limit. Make it smaller, or publish with --no-marker. No HTML was uploaded.`
1625
+ };
1626
+ const manifest = publishManifest(store);
1627
+ const marker = readMarker(bytes);
1628
+ const detect = action.action === void 0 && options.idempotencyKey === void 0;
1629
+ let automatic;
1630
+ let fallback = false;
1631
+ if (detect && marker) {
1632
+ const target = await getDocument(server, marker, request).catch((error) => {
1633
+ if (error instanceof DocumentRequestError && error.status === "unavailable") return;
1634
+ throw error;
1297
1635
  });
1298
- let selection;
1299
- try {
1300
- selection = (await input.question("Number, r to refresh, or q to cancel: ", { signal: options.signal })).trim();
1301
- } finally {
1302
- input.close();
1303
- }
1304
- if (selection === "q") return {
1305
- status: "cancelled",
1306
- message: "Publication cancelled."
1636
+ if (!target) return {
1637
+ status: "marker_unavailable",
1638
+ message: `Document ${marker} from this file's marker not found or not yours. No HTML was uploaded. Use --new to publish a separate document${options.noMarker ? "" : " and point the marker at it"}.`,
1639
+ next: {
1640
+ command: "publish",
1641
+ args: [
1642
+ options.file,
1643
+ ...contextArgs,
1644
+ "--new"
1645
+ ]
1646
+ }
1307
1647
  };
1308
- const choice = address.choices[Number(selection) - 1];
1309
- if (selection !== "r" && !choice) continue;
1310
- address = await updateWorkspaceAddress(server, selection === "r" ? { action: "refresh" } : {
1311
- action: "select",
1312
- choiceId: choice.choiceId
1313
- }, request);
1648
+ const entry = await manifest.find(source.path);
1649
+ if (entry?.documentId === target.id) {
1650
+ action = {
1651
+ action: "revise",
1652
+ documentId: target.id,
1653
+ expectedRevisionId: entry.revisionId
1654
+ };
1655
+ automatic = "manifest";
1656
+ } else if (target.repository === (source.repository ?? null)) {
1657
+ action = {
1658
+ action: "revise",
1659
+ documentId: target.id,
1660
+ expectedRevisionId: target.revisionId
1661
+ };
1662
+ automatic = "marker";
1663
+ } else return {
1664
+ status: "repository_mismatch",
1665
+ document: target,
1666
+ message: `This file's marker names ${target.url}, a document from ${target.repository ?? "a loose file"}, but this file ${source.repository ? `belongs to ${source.repository}` : "is a loose file"}. No document changed. To revise that document anyway, publish with --revise ${target.id} --expected-revision ${target.revisionId}, or use --new for a separate document. Use --json for complete follow-up arguments.`,
1667
+ resolutions: resolutions(options.file, contextArgs, [target])
1668
+ };
1669
+ } else if (detect) {
1670
+ const entry = await manifest.find(source.path);
1671
+ if (entry) {
1672
+ action = {
1673
+ action: "revise",
1674
+ documentId: entry.documentId,
1675
+ expectedRevisionId: entry.revisionId
1676
+ };
1677
+ automatic = "manifest";
1678
+ fallback = true;
1679
+ }
1314
1680
  }
1315
- if (address.status === "choice_required") return {
1316
- ...address,
1317
- message: `Choose a generated workspace address and repeat publish with --address-choice <choiceId>, or --refresh-addresses.\n${address.choices.map((choice) => `${choice.hostname}: ${choice.choiceId}`).join("\n")}`,
1318
- next: {
1319
- command: "publish",
1320
- args: [
1321
- options.file,
1322
- "--server",
1323
- server,
1324
- "--address-choice",
1325
- "<choiceId>"
1326
- ]
1681
+ let uploaded = bytes;
1682
+ const send = async () => {
1683
+ for (let tries = 1;; tries++) {
1684
+ const target = action.action === "revise" ? action.documentId : skipMarker ? void 0 : proposedDocumentId(idempotencyKey);
1685
+ const proposal = action.action === "revise" || !target ? action : {
1686
+ ...action,
1687
+ documentId: target
1688
+ };
1689
+ uploaded = target && !skipMarker ? withMarker(bytes, target) : bytes;
1690
+ started = true;
1691
+ const result = await publishDocument(server, {
1692
+ bytes: uploaded,
1693
+ sourcePath: source.sourcePath,
1694
+ ...source.repository ? { repository: source.repository } : {},
1695
+ cliVersion: version,
1696
+ idempotencyKey,
1697
+ ...proposal
1698
+ }, request);
1699
+ if (result.status !== "document_id_taken" || tries === 8) return result;
1700
+ idempotencyKey = publicationKey();
1327
1701
  }
1328
1702
  };
1329
- if (address.status !== "allocated") return {
1330
- ...address,
1331
- message: "Workspace address is unavailable. Sign in again or check admission."
1332
- };
1333
- const send = () => publishDocument(server, {
1334
- bytes,
1335
- sourcePath: source.sourcePath,
1336
- sourceContext: source.sourceContext,
1337
- idempotencyKey,
1338
- ...action
1339
- }, request);
1340
1703
  let result = await send();
1704
+ if (fallback && result.status === "unavailable") {
1705
+ action = {};
1706
+ automatic = void 0;
1707
+ idempotencyKey = publicationKey();
1708
+ result = await send();
1709
+ }
1710
+ const revisedFromManifest = automatic === "manifest";
1341
1711
  if (result.status === "source_collision" && options.interactive) {
1342
1712
  const selected = await choosePublication(result.candidates, options.signal);
1343
1713
  if (!selected) return {
1344
1714
  status: "cancelled",
1345
- message: "Publication cancelled. No document changed."
1715
+ message: "Publication cancelled. No document changed.",
1716
+ next: {
1717
+ command: "publish",
1718
+ args: [options.file, ...contextArgs]
1719
+ }
1346
1720
  };
1347
1721
  action = selected;
1348
1722
  result = await send();
1349
1723
  }
1350
- const message = result.status === "published" ? `Published ${result.document.visibility === "private" ? "privately" : "publicly"}: ${result.document.url}` : result.status === "source_collision" ? `Source path already published. No document changed.\n${result.candidates.map((candidate) => candidate.url).join("\n")}\nUse --new for a separate document, or --revise <documentId> --expected-revision <revisionId> from a candidate. Use --json for complete follow-up arguments.` : result.status === "revision_conflict" ? `The selected document changed. No revision was published. Inspect ${result.document.url} and select its current revision before trying again.` : result.status === "idempotency_conflict" ? "That idempotency key belongs to different content, source metadata or publication action. Reuse the original input or choose a new key." : result.status === "unavailable" ? "The selected document is unavailable for this source path. Run publish without action flags to find current candidates." : "Allocate your workspace address before publishing.";
1724
+ let unrecorded = "";
1725
+ let manifestWarning = "";
1726
+ let superseded = false;
1727
+ if (result.status === "published") {
1728
+ const { id, revisionId } = result.document;
1729
+ const startedAt = idempotencyKey === options.idempotencyKey ? keyTime(idempotencyKey) : Date.now();
1730
+ superseded = await manifest.record(source.path, {
1731
+ documentId: id,
1732
+ revisionId,
1733
+ startedAt
1734
+ }).then((recorded) => !recorded, (error) => {
1735
+ manifestWarning = ` Could not update the publish manifest, so a plain publish of this file may not revise this document. To continue it, publish with --revise ${id} --expected-revision ${revisionId}. ${error instanceof Error ? error.message : ""}`;
1736
+ return false;
1737
+ });
1738
+ }
1739
+ if (result.status === "published" && !superseded && !uploaded.equals(bytes)) try {
1740
+ if (readMarker(uploaded) !== result.document.id) throw new Error("The published document ID differs from the marker.");
1741
+ await writeMarker(source.path, bytes, uploaded);
1742
+ } catch (error) {
1743
+ unrecorded += ` Could not write the document marker to the local file, so the file has no marker for this document and differs from the published bytes. ${error instanceof Error ? error.message : ""}`;
1744
+ }
1745
+ if (result.status === "published" && utf16) unrecorded += " This file is UTF-16, so no document marker was added: the ASCII marker would corrupt its encoding. Publish it with --no-marker to skip this warning.";
1746
+ unrecorded += manifestWarning;
1747
+ if (superseded) unrecorded += " This file's publish manifest entry names a newer publication, so the entry and the file's marker stay unchanged.";
1748
+ const change = action.action === "revise" ? "revised" : "created";
1749
+ const message = result.status === "published" ? `Published ${result.document.visibility === "private" ? "privately" : "publicly"} as a new ${change === "revised" ? "revision" : "document"}: ${result.document.url}${unrecorded}` : result.status === "source_collision" ? `Source path already published. No document changed.\n${result.candidates.map((candidate) => candidate.url).join("\n")}\nUse --new for a separate document, or --revise <documentId> --expected-revision <revisionId> from a candidate. Use --json for complete follow-up arguments.` : result.status === "revision_conflict" ? revisedFromManifest ? `This file's document changed after this machine last published it, for example from another machine or the dashboard. No revision was published. Inspect ${result.document.url}, then revise its current revision or use --new. Use --json for complete follow-up arguments.` : `The selected document changed. No revision was published. Inspect ${result.document.url} and select its current revision before trying again.` : result.status === "idempotency_conflict" ? "That idempotency key belongs to different content, source metadata or publication action. Reuse the original input or choose a new key." : result.status === "unavailable" ? "Document not found or not yours. No revision was published." : result.status === "document_id_taken" ? "Every proposed document ID was already taken. No document changed. Publish again." : `Your workspace address is not selected yet. Run ${commandLine("login", selection.args)} to choose one, then publish again. No document changed.`;
1351
1750
  return {
1352
1751
  ...result,
1752
+ ...result.status === "published" ? { change } : {},
1353
1753
  idempotencyKey,
1354
1754
  message,
1355
- ...result.status === "source_collision" ? { resolutions: resolutions(options.file, server, result.candidates) } : {},
1356
- ...result.status === "revision_conflict" ? { resolutions: resolutions(options.file, server, [result.document]) } : {}
1755
+ ...result.status === "source_collision" ? { resolutions: resolutions(options.file, contextArgs, result.candidates) } : {},
1756
+ ...result.status === "revision_conflict" ? { resolutions: resolutions(options.file, contextArgs, [result.document]) } : {},
1757
+ ...result.status === "address_required" ? { next: login } : {}
1357
1758
  };
1358
1759
  } catch (error) {
1760
+ const retry = [...started || options.idempotencyKey !== void 0 ? ["--idempotency-key", idempotencyKey] : [], ...actionArgs(action)];
1761
+ const next = {
1762
+ command: "publish",
1763
+ args: [
1764
+ options.file,
1765
+ ...contextArgs,
1766
+ ...retry
1767
+ ]
1768
+ };
1769
+ const guidance = retry.length ? `Retry with ${retry.join(" ")}.` : "Run the same command again.";
1359
1770
  if (options.signal.aborted) return {
1360
1771
  status: "interrupted",
1361
1772
  idempotencyKey,
1362
- message: `Publication interrupted. Retry with --idempotency-key ${idempotencyKey} ${actionArgs(action).join(" ")}.`,
1363
- next: {
1364
- command: "publish",
1365
- args: [
1366
- options.file,
1367
- "--server",
1368
- server,
1369
- "--idempotency-key",
1370
- idempotencyKey,
1371
- ...actionArgs(action)
1372
- ]
1373
- }
1773
+ message: `Publication interrupted. ${guidance}`,
1774
+ next
1374
1775
  };
1375
- if (error instanceof DocumentRequestError) return {
1776
+ if (error instanceof DocumentRequestError && isStatus(error.status)) return {
1376
1777
  status: error.status,
1377
- message: error.message,
1378
- idempotencyKey
1778
+ message: error.status === "signed_out" ? `Your installation is not authorized. Run ${commandLine("login", selection.args)} again.` : error.message,
1779
+ idempotencyKey,
1780
+ ...error.status === "signed_out" ? { next: login } : {}
1379
1781
  };
1380
1782
  return {
1381
1783
  status: "request_failed",
1382
1784
  idempotencyKey,
1383
- message: `Publication did not finish. Retry with --idempotency-key ${idempotencyKey} ${actionArgs(action).join(" ")}. ${error instanceof Error ? error.message : "Check the server and file."}`,
1384
- next: {
1385
- command: "publish",
1386
- args: [
1387
- options.file,
1388
- "--server",
1389
- server,
1390
- "--idempotency-key",
1391
- idempotencyKey,
1392
- ...actionArgs(action)
1393
- ]
1394
- }
1785
+ message: `Publication did not finish. ${guidance} ${error instanceof Error ? error.message : "Check the server and file."}`,
1786
+ next
1395
1787
  };
1396
1788
  } finally {
1397
1789
  await store.close();
1398
1790
  }
1399
1791
  }
1400
1792
  //#endregion
1401
- //#region package.json
1402
- var version = "0.1.1";
1403
- //#endregion
1404
- //#region src/cli.ts
1405
- const args = process.argv.slice(2);
1406
- const separator = args.indexOf("--");
1407
- const flagArgs = args.slice(0, separator === -1 ? void 0 : separator);
1408
- const parseBoolean = Schema.decodeUnknownOption(Config.Boolean);
1409
- const booleanValue = (value) => Option.getOrUndefined(parseBoolean(value));
1793
+ //#region src/authorization.ts
1794
+ async function authRequest(selection, path, body, signal) {
1795
+ const response = await fetch(`${selection.origin}/api/auth${path}`, {
1796
+ method: "POST",
1797
+ headers: { "Content-Type": "application/json" },
1798
+ body: JSON.stringify(body),
1799
+ redirect: "error",
1800
+ credentials: "omit",
1801
+ cache: "no-store",
1802
+ signal: AbortSignal.any([signal, AbortSignal.timeout(1e4)])
1803
+ });
1804
+ if (response.status >= 500) throw new Error(`Authorization server returned HTTP ${response.status}. Retry later with ${commandLine("login", [...selection.args, "--complete"])} if you have a pending code.`);
1805
+ let value;
1806
+ try {
1807
+ value = await response.json();
1808
+ } catch {
1809
+ throw new Error(`Authorization server returned an unreadable response (HTTP ${response.status}). Check the server and retry.`);
1810
+ }
1811
+ if (!value || typeof value !== "object") throw new Error("Invalid authorization response. Check the server and retry.");
1812
+ const data = value;
1813
+ if (!response.ok && typeof data.error !== "string") throw new Error(`Authorization server returned HTTP ${response.status}. Retry later.`);
1814
+ return data;
1815
+ }
1816
+ function pendingResult(pending, selection) {
1817
+ return {
1818
+ status: "authorization_pending",
1819
+ message: "Approve this installation in your browser, then run the completion command.",
1820
+ verificationUri: pending.verificationUri,
1821
+ verificationUriComplete: pending.verificationUriComplete,
1822
+ userCode: pending.userCode,
1823
+ expiresAt: new Date(pending.expiresAt).toISOString(),
1824
+ retryAfterSeconds: Math.max(0, Math.ceil((pending.nextPollAt - Date.now()) / 1e3)),
1825
+ next: {
1826
+ command: "login",
1827
+ args: [...selection.args, "--complete"]
1828
+ }
1829
+ };
1830
+ }
1831
+ /** Authorize the installation, then establish its personal workspace address. */
1832
+ async function authorize(options) {
1833
+ const selection = selectServer(options.server);
1834
+ if (options.addressChoice !== void 0 && options.refreshAddresses) return {
1835
+ status: "invalid_action",
1836
+ message: "Use --address-choice <choiceId> or --refresh-addresses, not both."
1837
+ };
1838
+ const { token, address: _accessAddress, ...result } = await authorizeInstallation(selection, options);
1839
+ if (result.status !== "authorized" || typeof token !== "string") return result;
1840
+ return onboardAddress(selection, token, result, {
1841
+ action: options.addressChoice !== void 0 ? {
1842
+ action: "select",
1843
+ choiceId: options.addressChoice
1844
+ } : { action: options.refreshAddresses ? "refresh" : "allocate" },
1845
+ interactive: options.addressInteractive,
1846
+ signal: options.signal
1847
+ });
1848
+ }
1849
+ async function onboardAddress(selection, token, authorized, options) {
1850
+ const server = selection.origin;
1851
+ const resume = {
1852
+ command: "login",
1853
+ args: [...selection.args]
1854
+ };
1855
+ const retry = {
1856
+ command: "login",
1857
+ args: [...selection.args, ...options.action.action === "select" ? ["--address-choice", options.action.choiceId] : []]
1858
+ };
1859
+ const login = (...args) => commandLine("login", [...selection.args, ...args]);
1860
+ const incomplete = `${authorized.message} Your workspace address is not selected yet, so publishing is not ready.`;
1861
+ const request = {
1862
+ token,
1863
+ signal: options.signal
1864
+ };
1865
+ const interrupted = {
1866
+ ...authorized,
1867
+ status: "interrupted",
1868
+ message: `${incomplete} Address selection was interrupted; your credential is kept. Run ${commandLine(retry.command, retry.args)} to choose one.`,
1869
+ next: retry
1870
+ };
1871
+ try {
1872
+ let address = await updateWorkspaceAddress(server, options.action, request);
1873
+ while (address.status === "choice_required" && options.interactive) {
1874
+ console.log(`${address.reason === "stale_choice" ? "That address choice is no longer available. " : "Authorization succeeded. "}Choose your workspace address:`);
1875
+ address.choices.forEach((choice, index) => console.log(`${index + 1}. ${choice.hostname}`));
1876
+ const input = createInterface({
1877
+ input: process.stdin,
1878
+ output: process.stdout
1879
+ });
1880
+ let selection;
1881
+ try {
1882
+ selection = (await input.question("Number, r to refresh, or q to cancel: ", { signal: options.signal })).trim();
1883
+ } catch (error) {
1884
+ if (error instanceof Error && error.name === "AbortError") return interrupted;
1885
+ throw error;
1886
+ } finally {
1887
+ input.close();
1888
+ }
1889
+ if (selection === "q") return {
1890
+ ...authorized,
1891
+ status: "cancelled",
1892
+ message: `${incomplete} Run ${login()} to choose one.`,
1893
+ next: resume
1894
+ };
1895
+ const choice = address.choices[Number(selection) - 1];
1896
+ if (selection !== "r" && !choice) continue;
1897
+ address = await updateWorkspaceAddress(server, choice ? {
1898
+ action: "select",
1899
+ choiceId: choice.choiceId
1900
+ } : { action: "refresh" }, request);
1901
+ }
1902
+ if (address.status === "allocated") return {
1903
+ ...authorized,
1904
+ address: {
1905
+ subdomain: address.subdomain,
1906
+ hostname: address.hostname
1907
+ },
1908
+ message: `${authorized.message} Workspace address: ${address.hostname}.`
1909
+ };
1910
+ if (address.status === "choice_required") return {
1911
+ ...authorized,
1912
+ ...address,
1913
+ message: `${incomplete} ${address.reason === "stale_choice" ? "That address choice is no longer available. " : ""}Choose a generated address and run ${login("--address-choice")} <choiceId>, or ${login("--refresh-addresses")} for new choices.\n${address.choices.map((choice) => `${choice.hostname}: ${choice.choiceId}`).join("\n")}`,
1914
+ resolutions: {
1915
+ select: address.choices.map((choice) => ({
1916
+ choiceId: choice.choiceId,
1917
+ hostname: choice.hostname,
1918
+ command: "login",
1919
+ args: [
1920
+ ...resume.args,
1921
+ "--address-choice",
1922
+ choice.choiceId
1923
+ ]
1924
+ })),
1925
+ refresh: {
1926
+ command: "login",
1927
+ args: [...resume.args, "--refresh-addresses"]
1928
+ }
1929
+ }
1930
+ };
1931
+ if (address.status === "reauthentication_required") return {
1932
+ ...authorized,
1933
+ status: address.status,
1934
+ message: `${incomplete} Sign in to the dashboard again so Pageship can verify your GitHub login, then run ${login()}.`,
1935
+ next: resume
1936
+ };
1937
+ if (address.status === "signed_out") return {
1938
+ ...authorized,
1939
+ status: address.status,
1940
+ message: `${incomplete} The credential was rejected. Run ${login("--restart")} to sign in again.`,
1941
+ next: {
1942
+ command: "login",
1943
+ args: [...selection.args, "--restart"]
1944
+ }
1945
+ };
1946
+ return {
1947
+ ...authorized,
1948
+ status: address.status,
1949
+ message: `${incomplete} Workspace access is unavailable. Check your admission with the server owner.`
1950
+ };
1951
+ } catch (error) {
1952
+ if (options.signal.aborted) return interrupted;
1953
+ return {
1954
+ ...authorized,
1955
+ status: "request_failed",
1956
+ message: `${incomplete} ${error instanceof Error ? error.message : "Could not update your workspace address."} Your credential is kept. Run ${commandLine(retry.command, retry.args)} to retry.`,
1957
+ next: retry
1958
+ };
1959
+ }
1960
+ }
1961
+ async function authorizeInstallation(selection, options) {
1962
+ const server = selection.origin;
1963
+ const login = (...args) => commandLine("login", [...selection.args, ...args]);
1964
+ const next = (...args) => ({
1965
+ command: "login",
1966
+ args: [...selection.args, ...args]
1967
+ });
1968
+ const compatibility = await checkCompatibility(server, { signal: options.signal });
1969
+ if (compatibility.status !== "compatible") return {
1970
+ ...compatibility,
1971
+ status: compatibility.status
1972
+ };
1973
+ const store = await credentialStore(server);
1974
+ try {
1975
+ const credentials = await store.read();
1976
+ if (credentials.token && !options.restart && !credentials.pending) {
1977
+ const access = await getWorkspace(server, {
1978
+ token: credentials.token,
1979
+ signal: options.signal
1980
+ });
1981
+ if (access.status === "admitted") return {
1982
+ ...access,
1983
+ status: "authorized",
1984
+ message: `Authorized as ${access.user.name}.`,
1985
+ token: credentials.token
1986
+ };
1987
+ if (access.status === "uninvited") return {
1988
+ status: "uninvited",
1989
+ message: `This account is no longer invited. Contact the server owner or run ${login("--restart")} for another account.`
1990
+ };
1991
+ delete credentials.token;
1992
+ await store.write(credentials);
1993
+ }
1994
+ if (options.restart) delete credentials.pending;
1995
+ if (!credentials.pending) {
1996
+ if (options.complete) return {
1997
+ status: "invalid_attempt",
1998
+ message: `No pending authorization. Run ${login()} to start a new attempt.`
1999
+ };
2000
+ const data = await authRequest(selection, "/device/code", { client_id: "pageship-cli" }, options.signal);
2001
+ if (typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.verification_uri_complete !== "string" || typeof data.expires_in !== "number" || data.expires_in <= 0 || typeof data.interval !== "number" || data.interval < 1) throw new Error("Invalid device authorization response. Check the server and retry.");
2002
+ for (const uri of [data.verification_uri, data.verification_uri_complete]) {
2003
+ const parsed = new URL(uri);
2004
+ if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.protocol === "http:" && new URL(server).protocol !== "http:") throw new Error("Invalid browser verification URL.");
2005
+ }
2006
+ credentials.pending = {
2007
+ deviceCode: data.device_code,
2008
+ userCode: data.user_code,
2009
+ verificationUri: data.verification_uri,
2010
+ verificationUriComplete: data.verification_uri_complete,
2011
+ expiresAt: Date.now() + data.expires_in * 1e3,
2012
+ interval: data.interval,
2013
+ nextPollAt: 0
2014
+ };
2015
+ await store.write(credentials);
2016
+ }
2017
+ const pending = credentials.pending;
2018
+ if (options.interactive) options.progress(`Open ${pending.verificationUriComplete}\nCheck code ${pending.userCode}. Only approve an installation you started.\nWaiting for approval. Press Ctrl+C to stop; you can resume with ${login("--complete")}.`);
2019
+ do {
2020
+ if (options.signal.aborted) return {
2021
+ status: "interrupted",
2022
+ message: `Authorization interrupted. Run ${login("--complete")} to resume, or ${login("--restart")} for a new code.`,
2023
+ next: next("--complete")
2024
+ };
2025
+ if (Date.now() >= pending.expiresAt) {
2026
+ delete credentials.pending;
2027
+ await store.write(credentials);
2028
+ return {
2029
+ status: "expired",
2030
+ message: `The authorization code expired. Run ${login()} again for a new code.`,
2031
+ next: next()
2032
+ };
2033
+ }
2034
+ if (!options.interactive && (!options.complete || Date.now() < pending.nextPollAt)) return pendingResult(pending, selection);
2035
+ if (Date.now() < pending.nextPollAt) await setTimeout$1(pending.nextPollAt - Date.now(), void 0, { signal: options.signal });
2036
+ pending.nextPollAt = Date.now() + pending.interval * 1e3;
2037
+ await store.write(credentials);
2038
+ const data = await authRequest(selection, "/device/token", {
2039
+ client_id: "pageship-cli",
2040
+ device_code: pending.deviceCode,
2041
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
2042
+ }, options.signal);
2043
+ if (typeof data.access_token === "string" && data.access_token.length > 0) {
2044
+ credentials.token = data.access_token;
2045
+ delete credentials.pending;
2046
+ await store.write(credentials);
2047
+ const access = await getWorkspace(server, {
2048
+ token: credentials.token,
2049
+ signal: options.signal
2050
+ });
2051
+ if (access.status === "signed_out") return {
2052
+ status: access.status,
2053
+ message: `Authorization finished, but the server rejected the new credential. Run ${login("--restart")} to sign in again.`,
2054
+ next: next("--restart")
2055
+ };
2056
+ if (access.status !== "admitted") return {
2057
+ status: access.status,
2058
+ message: "Authorization finished, but workspace access is unavailable. Check your admission with the server owner."
2059
+ };
2060
+ return {
2061
+ ...access,
2062
+ status: "authorized",
2063
+ message: `Authorized as ${access.user.name}. Your credential is stored privately for this server.`,
2064
+ token: credentials.token
2065
+ };
2066
+ }
2067
+ if (data.error === "slow_down") {
2068
+ pending.interval += 5;
2069
+ pending.nextPollAt = Date.now() + pending.interval * 1e3;
2070
+ await store.write(credentials);
2071
+ } else if (data.error !== "authorization_pending") {
2072
+ delete credentials.pending;
2073
+ await store.write(credentials);
2074
+ const status = data.error === "access_denied" ? "denied" : data.error === "expired_token" ? "expired" : "invalid_attempt";
2075
+ return {
2076
+ status,
2077
+ message: `Authorization ${status.replaceAll("_", " ")}. Run ${login()} again for a new code.`,
2078
+ ...status === "expired" ? { next: next() } : {}
2079
+ };
2080
+ }
2081
+ if (!options.interactive) return pendingResult(pending, selection);
2082
+ } while (!options.signal.aborted);
2083
+ return {
2084
+ status: "interrupted",
2085
+ message: `Authorization interrupted. Run ${login("--complete")} to resume.`,
2086
+ next: next("--complete")
2087
+ };
2088
+ } catch (error) {
2089
+ if (options.signal.aborted) return {
2090
+ status: "interrupted",
2091
+ message: `Authorization interrupted. Run ${login("--complete")} to resume or ${login("--restart")} to start again.`,
2092
+ next: next("--complete")
2093
+ };
2094
+ throw error;
2095
+ } finally {
2096
+ await store.close();
2097
+ }
2098
+ }
2099
+ async function identify(serverValue, signal) {
2100
+ const selection = selectServer(serverValue);
2101
+ const server = selection.origin;
2102
+ const login = commandLine("login", selection.args);
2103
+ const next = {
2104
+ command: "login",
2105
+ args: selection.args
2106
+ };
2107
+ const compatibility = await checkCompatibility(server, { signal });
2108
+ if (compatibility.status !== "compatible") return {
2109
+ ...compatibility,
2110
+ status: compatibility.status
2111
+ };
2112
+ const store = await credentialStore(server, true);
2113
+ try {
2114
+ const { token } = await store.read();
2115
+ if (!token) return {
2116
+ status: "signed_out",
2117
+ message: `This installation is not authorized. Run ${login} to begin.`,
2118
+ next
2119
+ };
2120
+ const access = await getWorkspace(server, {
2121
+ token,
2122
+ signal
2123
+ });
2124
+ return {
2125
+ ...access,
2126
+ ...access.status === "signed_out" ? { next } : {},
2127
+ message: access.status === "admitted" ? `Signed in as ${access.user.name}. Personal workspace: ${access.workspace.id}${access.installation ? `\nCLI installation: ${access.installation.id}` : ""}` : access.status === "uninvited" ? "This account is not invited. Contact the server owner." : `Your credential was revoked, expired or is invalid. Run ${login} to sign in again.`
2128
+ };
2129
+ } finally {
2130
+ await store.close();
2131
+ }
2132
+ }
2133
+ //#endregion
2134
+ //#region src/documents.ts
2135
+ /** Checks compatibility and the stored credential; the server enforces ownership and admission. */
2136
+ async function asOwner(serverValue, signal, operation) {
2137
+ const selection = selectServer(serverValue);
2138
+ const server = selection.origin;
2139
+ const next = {
2140
+ command: "login",
2141
+ args: selection.args
2142
+ };
2143
+ const login = commandLine(next.command, next.args);
2144
+ const compatibility = await checkCompatibility(server, { signal });
2145
+ if (compatibility.status !== "compatible") return {
2146
+ ...compatibility,
2147
+ status: compatibility.status
2148
+ };
2149
+ const store = await credentialStore(server, true);
2150
+ let token;
2151
+ try {
2152
+ ({token} = await store.read());
2153
+ } finally {
2154
+ await store.close();
2155
+ }
2156
+ if (!token) return {
2157
+ status: "signed_out",
2158
+ message: `This installation is not authorized. Run ${login} first.`,
2159
+ next
2160
+ };
2161
+ try {
2162
+ return await operation(selection, {
2163
+ token,
2164
+ signal
2165
+ });
2166
+ } catch (error) {
2167
+ if (!(error instanceof DocumentRequestError && isStatus(error.status))) throw error;
2168
+ return {
2169
+ status: error.status,
2170
+ message: error.status === "signed_out" ? `Your credential was revoked, expired or is invalid. Run ${login} to sign in again.` : error.status === "uninvited" ? "This account is not invited. Contact the server owner." : error.message,
2171
+ ...error.status === "signed_out" ? { next } : {}
2172
+ };
2173
+ }
2174
+ }
2175
+ function listDocuments(options) {
2176
+ return asOwner(options.server, options.signal, async ({ origin, args }, request) => {
2177
+ const page = await getDocuments(origin, {
2178
+ ...request,
2179
+ cursor: options.cursor
2180
+ });
2181
+ const next = page.nextCursor ? {
2182
+ command: "list",
2183
+ args: [
2184
+ ...args,
2185
+ "--cursor",
2186
+ page.nextCursor
2187
+ ]
2188
+ } : void 0;
2189
+ return {
2190
+ ...page,
2191
+ ...next ? { next } : {},
2192
+ message: [
2193
+ page.documents.length ? "Documents:" : options.cursor ? "No more documents." : "No documents.",
2194
+ ...page.documents.map((document) => `${document.id} ${document.visibility} ${document.title}\n` + (document.repository ? ` repository ${document.repository}\n` : "") + ` source ${document.sourcePath}\n current revision ${document.revisionId}\n ${document.url}`),
2195
+ ...next ? [`More documents: run ${commandLine(next.command, next.args)}`] : []
2196
+ ].join("\n")
2197
+ };
2198
+ });
2199
+ }
2200
+ function listRevisions(options) {
2201
+ return asOwner(options.server, options.signal, async ({ origin, args }, request) => {
2202
+ const page = await getDocumentRevisions(origin, options.documentId, {
2203
+ ...request,
2204
+ cursor: options.cursor
2205
+ });
2206
+ const next = page.nextCursor ? {
2207
+ command: "history",
2208
+ args: [
2209
+ options.documentId,
2210
+ ...args,
2211
+ "--cursor",
2212
+ page.nextCursor
2213
+ ]
2214
+ } : void 0;
2215
+ return {
2216
+ ...page,
2217
+ documentId: options.documentId,
2218
+ ...next ? { next } : {},
2219
+ message: [
2220
+ page.revisions.length ? `Revisions of ${options.documentId}, newest first:` : "No more revisions.",
2221
+ ...page.revisions.map((revision) => `${revision.id}${revision.isCurrent ? " current" : ""} ${new Date(revision.createdAt).toISOString()} ${revision.byteLength} bytes\n ${revision.url}`),
2222
+ ...next ? [`More revisions: run ${commandLine(next.command, next.args)}`] : []
2223
+ ].join("\n")
2224
+ };
2225
+ });
2226
+ }
2227
+ function invalidOutput(output, error) {
2228
+ return {
2229
+ status: "invalid_output",
2230
+ output,
2231
+ message: `Could not write ${output}. ${error instanceof Error ? error.message : "Check the path and try again."}`
2232
+ };
2233
+ }
2234
+ function downloadDocument(options) {
2235
+ const output = resolve(options.output);
2236
+ return asOwner(options.server, options.signal, async ({ origin }, request) => {
2237
+ const bytes = await getDocumentContent(origin, options.documentId, {
2238
+ ...request,
2239
+ revisionId: options.revisionId
2240
+ });
2241
+ let file;
2242
+ try {
2243
+ file = await open(output, "wx");
2244
+ } catch (error) {
2245
+ if (error.code === "EEXIST") return {
2246
+ status: "output_exists",
2247
+ output,
2248
+ message: `${output} already exists and was not changed. Choose another --output path or remove the file first.`
2249
+ };
2250
+ return invalidOutput(output, error);
2251
+ }
2252
+ try {
2253
+ await file.writeFile(bytes);
2254
+ } catch (error) {
2255
+ await file.close();
2256
+ await rm(output, { force: true });
2257
+ return invalidOutput(output, error);
2258
+ }
2259
+ await file.close();
2260
+ return {
2261
+ status: "downloaded",
2262
+ documentId: options.documentId,
2263
+ revisionId: options.revisionId ?? null,
2264
+ output,
2265
+ byteLength: bytes.byteLength,
2266
+ message: `Saved ${bytes.byteLength} bytes of ${options.documentId}${options.revisionId ? ` revision ${options.revisionId}` : ""} to ${output}.`
2267
+ };
2268
+ });
2269
+ }
2270
+ function changeVisibility(options) {
2271
+ return asOwner(options.server, options.signal, async ({ origin }, request) => {
2272
+ const result = await updateDocumentVisibility(origin, options.documentId, options.visibility, request);
2273
+ return {
2274
+ ...result,
2275
+ message: result.document.visibility === "public" ? `${result.document.id} is public. Anyone with the URL can read its current revision: ${result.document.url}` : `${result.document.id} is private. Only you can read it after signing in: ${result.document.url}`
2276
+ };
2277
+ });
2278
+ }
2279
+ function restoreRevision(options) {
2280
+ const { documentId, revisionId, expectedRevisionId } = options;
2281
+ const idempotencyKey = options.idempotencyKey ?? randomUUID();
2282
+ return asOwner(options.server, options.signal, async ({ origin, args }, request) => {
2283
+ const retry = {
2284
+ command: "restore",
2285
+ args: [
2286
+ documentId,
2287
+ ...args,
2288
+ "--revision",
2289
+ revisionId,
2290
+ "--expected-revision",
2291
+ expectedRevisionId,
2292
+ "--idempotency-key",
2293
+ idempotencyKey
2294
+ ]
2295
+ };
2296
+ let result;
2297
+ try {
2298
+ result = await restoreDocumentRevision(origin, documentId, {
2299
+ revisionId,
2300
+ expectedRevisionId,
2301
+ idempotencyKey
2302
+ }, request);
2303
+ } catch (error) {
2304
+ if (error instanceof DocumentRequestError) throw error;
2305
+ return {
2306
+ status: options.signal.aborted ? "interrupted" : "request_failed",
2307
+ idempotencyKey,
2308
+ message: `Restoration ${options.signal.aborted ? "was interrupted" : "did not finish"} and may have completed. Retry with --idempotency-key ${idempotencyKey}; it returns the original result instead of restoring twice.${options.signal.aborted || !(error instanceof Error) ? "" : ` ${error.message}`}`,
2309
+ next: retry
2310
+ };
2311
+ }
2312
+ if (result.status === "idempotency_conflict") return {
2313
+ ...result,
2314
+ idempotencyKey,
2315
+ message: "That idempotency key belongs to a different restoration. Reuse the original arguments, or omit --idempotency-key for a new restoration."
2316
+ };
2317
+ if (result.status === "revision_conflict") return {
2318
+ ...result,
2319
+ idempotencyKey,
2320
+ message: `${documentId} changed after you inspected its history. No revision was restored. Its current revision is now ${result.document.revisionId}. Inspect the history again, then restore with --expected-revision ${result.document.revisionId} only if you still want to replace it.`,
2321
+ next: {
2322
+ command: "history",
2323
+ args: [documentId, ...args]
2324
+ }
2325
+ };
2326
+ return {
2327
+ ...result,
2328
+ idempotencyKey,
2329
+ message: `Restored revision ${revisionId} as new current revision ${result.document.revisionId}. Earlier revisions remain in history, and ${documentId} stays ${result.document.visibility}: ${result.document.url}`
2330
+ };
2331
+ });
2332
+ }
2333
+ //#endregion
2334
+ //#region src/skill-files.ts
2335
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/u;
2336
+ /** The CLI version a packed SKILL.md was stamped with, or undefined for an unstamped one. */
2337
+ function skillVersion(text) {
2338
+ const fields = frontmatter.exec(text)?.[1];
2339
+ if (fields === void 0) return void 0;
2340
+ return (/^metadata:[ \t]*\r?\n(?:[ \t]+.*\r?\n)*?[ \t]+version:[ \t]*(.+?)[ \t]*$/mu.exec(fields)?.[1])?.replace(/^(["'])(.*)\1$/u, "$2");
2341
+ }
2342
+ //#endregion
2343
+ //#region src/skills.ts
2344
+ const isSkillName = (name) => /^pageship(?:-[a-z0-9-]+)?$/u.test(name);
2345
+ /** Third-party skills the setup guide offers. The CLI only looks for them. */
2346
+ const recommendedSkills = ["frontend-design", "design-taste-frontend"];
2347
+ /** The oldest Node major that runs the authoring skills' scripts. */
2348
+ const authoringNodeMajor = 20;
2349
+ const setupGuide = "https://pageship.dev/setup-guide.md";
2350
+ const installNext = {
2351
+ command: "skills",
2352
+ args: ["install"]
2353
+ };
2354
+ /** The output of `node --version` from PATH, or undefined when it does not run. */
2355
+ const nodeOnPath = () => new Promise((resolve) => {
2356
+ execFile("node", ["--version"], {
2357
+ timeout: 5e3,
2358
+ windowsHide: true
2359
+ }, (error, stdout) => resolve(error ? void 0 : stdout.trim()));
2360
+ });
2361
+ const Entry = Schema.Union([Schema.Struct({
2362
+ version: Schema.String,
2363
+ hash: Schema.String
2364
+ }), Schema.Struct({
2365
+ version: Schema.String,
2366
+ link: Schema.String
2367
+ })]);
2368
+ /** Maps each folder or link the CLI wrote to what it wrote there. */
2369
+ const SkillsRecord = Schema.fromJsonString(Schema.Record(Schema.String, Entry));
2370
+ const decodeRecord = Schema.decodeUnknownOption(SkillsRecord);
2371
+ const absent = (error) => {
2372
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return void 0;
2373
+ throw error;
2374
+ };
2375
+ const isDirectory = (path) => stat(path).then((info) => info.isDirectory(), () => false);
2376
+ const hasSkill = (folder) => stat(join(folder, "SKILL.md")).then((info) => info.isFile(), () => false);
2377
+ const names = async (path) => (await readdir(path).catch(absent) ?? []).filter(isSkillName).sort();
2378
+ /** A hash of a folder's paths and contents. Links count by their target, unfollowed. */
2379
+ async function folderHash(path) {
2380
+ const hash = createHash("sha256");
2381
+ const files = (await readdir(path, {
2382
+ recursive: true,
2383
+ withFileTypes: true
2384
+ })).map((entry) => ({
2385
+ entry,
2386
+ name: relative(path, join(entry.parentPath, entry.name))
2387
+ })).map(({ entry, name }) => ({
2388
+ entry,
2389
+ name: name.split("\\").join("/")
2390
+ })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
2391
+ for (const { entry, name } of files) {
2392
+ const full = join(entry.parentPath, entry.name);
2393
+ if (entry.isDirectory()) hash.update(`d ${name}\0`);
2394
+ else if (entry.isSymbolicLink()) hash.update(`l ${name}\0${await readlink(full)}\0`);
2395
+ else {
2396
+ const contents = await readFile(full);
2397
+ hash.update(`f ${name}\0${contents.length}\0`).update(contents);
2398
+ }
2399
+ }
2400
+ return hash.digest("hex");
2401
+ }
2402
+ /** Whether the folder or link at a path is absent, or still holds what the CLI recorded. */
2403
+ async function unchanged(path, entry) {
2404
+ const info = await lstat(path).catch(absent);
2405
+ if (!info) return true;
2406
+ if (!entry) return false;
2407
+ if ("link" in entry) return info.isSymbolicLink() && await readlink(path) === entry.link;
2408
+ return info.isDirectory() && await folderHash(path) === entry.hash;
2409
+ }
2410
+ /** Copy a folder through a hidden temporary sibling, so an agent never loads half a skill. */
2411
+ async function replaceFolder(source, target) {
2412
+ const temporary = join(target, "..", `.${randomUUID()}.tmp`);
2413
+ try {
2414
+ await cp(source, temporary, { recursive: true });
2415
+ await rm(target, {
2416
+ recursive: true,
2417
+ force: true
2418
+ });
2419
+ await rename(temporary, target);
2420
+ } finally {
2421
+ await rm(temporary, {
2422
+ recursive: true,
2423
+ force: true
2424
+ });
2425
+ }
2426
+ }
2427
+ const parseMajor = (version) => Number(/^v?(\d+)\./u.exec(version)?.[1] ?? NaN);
2428
+ const older = (installed, cli) => {
2429
+ const parts = (version) => version.split(/[.+-]/u).slice(0, 3).map(Number);
2430
+ const [a, b] = [parts(installed), parts(cli)];
2431
+ for (let index = 0; index < 3; index++) {
2432
+ if (!(a[index] >= 0)) return true;
2433
+ if (a[index] !== b[index]) return a[index] < b[index];
2434
+ }
2435
+ return false;
2436
+ };
2437
+ /** The recommended skills and Node that the CLI could not find. It installs none of them. */
2438
+ async function findMissing(environment) {
2439
+ const { home } = environment;
2440
+ const cache = join(home, ".claude", "plugins", "cache");
2441
+ const plugins = [];
2442
+ for (const marketplace of await readdir(cache).catch(() => void 0) ?? []) for (const plugin of await readdir(join(cache, marketplace)).catch(() => void 0) ?? []) for (const version of await readdir(join(cache, marketplace, plugin)).catch(() => void 0) ?? []) plugins.push(join(cache, marketplace, plugin, version, "skills"));
2443
+ const missing = [];
2444
+ for (const name of recommendedSkills) {
2445
+ const places = [
2446
+ join(home, ".agents", "skills"),
2447
+ join(home, ".claude", "skills"),
2448
+ ...plugins
2449
+ ];
2450
+ if (!(await Promise.all(places.map((place) => hasSkill(join(place, name))))).includes(true)) missing.push(name);
2451
+ }
2452
+ const node = await environment.nodeVersion();
2453
+ if (node === void 0 || !(parseMajor(node) >= authoringNodeMajor)) missing.push("node");
2454
+ return missing;
2455
+ }
2456
+ /** One line naming what was not found, or undefined when everything was. */
2457
+ function notFound(missing) {
2458
+ if (missing.length === 0) return void 0;
2459
+ const named = missing.map((entry) => entry === "node" ? `Node ${authoringNodeMajor} or later` : entry);
2460
+ return `${named.length > 1 ? `${named.slice(0, -1).join(", ")} and ${named.at(-1)}` : named[0]} ${named.length > 1 ? "were" : "was"} not found. The setup guide covers them: download ${setupGuide} and read all of it.`;
2461
+ }
2462
+ /**
2463
+ * Whether the Pageship skills in ~/.agents/skills are missing, current, or older than this
2464
+ * CLI, and what else was not found. Only reads.
2465
+ */
2466
+ async function inspectSkills(environment) {
2467
+ const shipped = await names(environment.source);
2468
+ const versions = await Promise.all(shipped.map((name) => readFile(join(environment.home, ".agents", "skills", name, "SKILL.md"), "utf8").then(skillVersion, () => null)));
2469
+ const skills = versions.every((version) => version === null) ? "missing" : versions.some((version) => version == null || older(version, environment.version)) ? "older" : "current";
2470
+ const missing = await findMissing(environment);
2471
+ const install = commandLine(installNext.command, installNext.args);
2472
+ return {
2473
+ skills,
2474
+ missing,
2475
+ message: [skills === "missing" ? `The Pageship skills are not installed. Run ${install} to install them.` : skills === "older" ? `The installed Pageship skills are older than this CLI (${environment.version}). Run ${install} to update them.` : `The Pageship skills match this CLI (${environment.version}).`, ...[notFound(missing)].filter((line) => line !== void 0)].join("\n")
2476
+ };
2477
+ }
2478
+ /**
2479
+ * Install the packed Pageship skills into ~/.agents/skills, and link them into
2480
+ * ~/.claude/skills when ~/.claude exists. It replaces only folders it recorded and that are
2481
+ * unchanged since, and otherwise writes nothing unless forced.
2482
+ */
2483
+ async function installSkills(environment, { force, signal }) {
2484
+ const { home, version } = environment;
2485
+ const shipped = (await readdir(environment.source).catch(() => [])).filter(isSkillName).sort();
2486
+ if (shipped.length === 0) throw new Error(`This CLI's packed skills are missing from ${environment.source}. Reinstall the CLI, then run the install again.`);
2487
+ const missing = await findMissing(environment);
2488
+ const agents = join(home, ".agents", "skills");
2489
+ const sameFolder = async (path) => await isDirectory(path) && await isDirectory(agents) && await realpath(path) === await realpath(agents);
2490
+ let claude = await isDirectory(join(home, ".claude")) ? join(home, ".claude", "skills") : void 0;
2491
+ if (claude && await sameFolder(claude)) claude = void 0;
2492
+ const roots = claude ? [agents, claude] : [agents];
2493
+ const directory = configDirectory();
2494
+ if (!isAbsolute(directory)) throw new Error("PAGESHIP_CONFIG_DIR must be an absolute private directory.");
2495
+ const recordPath = join(directory, "skills.json");
2496
+ const raw = await readFile(recordPath, "utf8").catch(absent);
2497
+ const record = raw === void 0 ? {} : Option.getOrElse(decodeRecord(raw), () => ({}));
2498
+ const targets = roots.flatMap((root) => shipped.map((name) => join(root, name)));
2499
+ const removed = (await Promise.all(roots.map(async (root) => (await names(root)).filter((name) => !shipped.includes(name)).map((name) => join(root, name)).filter((path) => Object.hasOwn(record, path))))).flat();
2500
+ const modified = [];
2501
+ if (!force) for (const path of [...targets, ...removed]) {
2502
+ signal.throwIfAborted();
2503
+ if (!await unchanged(path, record[path])) modified.push(path);
2504
+ }
2505
+ if (modified.length > 0) {
2506
+ const resolution = {
2507
+ command: "skills",
2508
+ args: ["install", "--force"]
2509
+ };
2510
+ return {
2511
+ status: "skills_modified",
2512
+ message: [
2513
+ "Did not install the Pageship skills. These folders changed after the CLI installed them, or the CLI has no record of installing them:",
2514
+ ...modified.map((path) => ` ${path}`),
2515
+ `Nothing was written. To replace them with this CLI's skills and lose those changes, run ${commandLine(resolution.command, resolution.args)}.`,
2516
+ ...[notFound(missing)].filter((line) => line !== void 0)
2517
+ ].join("\n"),
2518
+ version,
2519
+ folders: modified,
2520
+ missing,
2521
+ resolutions: { force: resolution }
2522
+ };
2523
+ }
2524
+ const next = { ...record };
2525
+ const folders = [];
2526
+ const links = [];
2527
+ signal.throwIfAborted();
2528
+ await mkdir(directory, {
2529
+ recursive: true,
2530
+ mode: 448
2531
+ });
2532
+ try {
2533
+ await mkdir(agents, { recursive: true });
2534
+ if (claude && await sameFolder(claude)) claude = void 0;
2535
+ if (claude) await mkdir(claude, { recursive: true });
2536
+ const hashes = /* @__PURE__ */ new Map();
2537
+ for (const name of shipped) {
2538
+ signal.throwIfAborted();
2539
+ const source = join(environment.source, name);
2540
+ hashes.set(name, await folderHash(source));
2541
+ await replaceFolder(source, join(agents, name));
2542
+ next[join(agents, name)] = {
2543
+ version,
2544
+ hash: hashes.get(name)
2545
+ };
2546
+ folders.push(join(agents, name));
2547
+ }
2548
+ if (claude) for (const name of shipped) {
2549
+ signal.throwIfAborted();
2550
+ const target = join(claude, name);
2551
+ if (environment.platform === "win32") {
2552
+ await replaceFolder(join(environment.source, name), target);
2553
+ next[target] = {
2554
+ version,
2555
+ hash: hashes.get(name)
2556
+ };
2557
+ folders.push(target);
2558
+ } else {
2559
+ await rm(target, {
2560
+ recursive: true,
2561
+ force: true
2562
+ });
2563
+ await symlink(join(agents, name), target, "dir");
2564
+ next[target] = {
2565
+ version,
2566
+ link: join(agents, name)
2567
+ };
2568
+ links.push(target);
2569
+ }
2570
+ }
2571
+ for (const path of removed) {
2572
+ signal.throwIfAborted();
2573
+ await rm(path, {
2574
+ recursive: true,
2575
+ force: true
2576
+ });
2577
+ delete next[path];
2578
+ }
2579
+ } finally {
2580
+ await replaceFile(recordPath, JSON.stringify(next, null, 2));
2581
+ }
2582
+ return {
2583
+ status: "skills_installed",
2584
+ message: [
2585
+ `Installed ${shipped.length} Pageship skills for CLI ${version} in ${agents}.`,
2586
+ ...claude ? [environment.platform === "win32" ? `Copied them to ${claude} for Claude Code.` : `Linked them from ${claude} for Claude Code.`] : [],
2587
+ ...removed.length > 0 ? [`Removed skills this version no longer ships: ${removed.join(", ")}.`] : [],
2588
+ ...[notFound(missing)].filter((line) => line !== void 0)
2589
+ ].join("\n"),
2590
+ version,
2591
+ folders,
2592
+ links,
2593
+ removed,
2594
+ missing
2595
+ };
2596
+ }
2597
+ //#endregion
2598
+ //#region src/status.ts
2599
+ /**
2600
+ * Report whether this installation can publish, using read-only requests. Status never
2601
+ * starts authorization, claims an address, opens a browser or writes to the configuration
2602
+ * directory, including creating it.
2603
+ */
2604
+ async function checkStatus(serverValue, signal, skillsEnvironment) {
2605
+ const [readiness, { message, ...skills }] = await Promise.all([checkReadiness(serverValue, signal), inspectSkills(skillsEnvironment)]);
2606
+ const install = readiness.status === "admitted" && !readiness.next && skills.skills !== "current";
2607
+ return {
2608
+ ...readiness,
2609
+ ...skills,
2610
+ message: `${readiness.message}\n${message}`,
2611
+ ...install ? { next: installNext } : {}
2612
+ };
2613
+ }
2614
+ async function checkReadiness(serverValue, signal) {
2615
+ const selection = selectServer(serverValue);
2616
+ const server = selection.origin;
2617
+ const loginNext = (...args) => ({
2618
+ command: "login",
2619
+ args: [...selection.args, ...args]
2620
+ });
2621
+ const shown = (next) => commandLine(next.command, next.args);
2622
+ let compatibility;
2623
+ try {
2624
+ compatibility = await checkCompatibility(server, { signal });
2625
+ } catch (error) {
2626
+ if (signal.aborted) throw error;
2627
+ return {
2628
+ status: "unreachable",
2629
+ message: `Could not reach a working Pageship API at ${server}. ${error instanceof Error ? error.message : ""}`.trim()
2630
+ };
2631
+ }
2632
+ const { status, message, ...service } = compatibility;
2633
+ const serviceLine = `${message} Server ${service.server.serverVersion}; supported API majors: ${service.server.supportedApiMajors.join(", ")}.`;
2634
+ if (status !== "compatible") return {
2635
+ ...compatibility,
2636
+ status,
2637
+ message: `${serviceLine}\nThis CLI requires API major ${service.requiredApiMajor}.`
2638
+ };
2639
+ let credentials;
2640
+ try {
2641
+ credentials = await inspectCredentials(server);
2642
+ } catch (error) {
2643
+ return {
2644
+ status: "configuration_unreadable",
2645
+ ...service,
2646
+ message: `${serviceLine}\nCould not read the Pageship configuration directory ${configDirectory()}. ${error instanceof Error ? error.message : ""}\nFix it, or set PAGESHIP_CONFIG_DIR to another absolute private directory. Then run ${commandLine("status", selection.args)} again.`
2647
+ };
2648
+ }
2649
+ if (!credentials.token) {
2650
+ const pending = credentials.pending;
2651
+ const waiting = pending !== void 0 && pending.expiresAt > Date.now();
2652
+ const next = waiting ? loginNext("--complete") : pending ? loginNext("--restart") : loginNext();
2653
+ return {
2654
+ status: "signed_out",
2655
+ ...service,
2656
+ message: waiting ? `${serviceLine}\nNot signed in. An authorization is waiting for browser approval. After approving it, run ${shown(next)}.` : pending ? `${serviceLine}\nNot signed in. The last authorization code expired. Run ${shown(next)} for a new code.` : `${serviceLine}\nNot signed in. Run ${shown(next)} to authorize this installation.`,
2657
+ next
2658
+ };
2659
+ }
2660
+ const access = await getWorkspace(server, {
2661
+ token: credentials.token,
2662
+ signal
2663
+ });
2664
+ if (access.status === "signed_out") return {
2665
+ status: "signed_out",
2666
+ ...service,
2667
+ message: `${serviceLine}\nThe stored credential was revoked, expired or is invalid. Run ${shown(loginNext())} to sign in again.`,
2668
+ next: loginNext()
2669
+ };
2670
+ if (access.status === "uninvited") return {
2671
+ status: "uninvited",
2672
+ ...service,
2673
+ message: `${serviceLine}\nThis GitHub account is not invited to this Pageship server. Contact the server owner, or run ${shown(loginNext("--restart"))} to use another account.`
2674
+ };
2675
+ const lines = [
2676
+ serviceLine,
2677
+ `Signed in as ${access.user.name}.`,
2678
+ `Personal workspace: ${access.workspace.id}`,
2679
+ ...access.installation ? [`CLI installation: ${access.installation.id}`] : []
2680
+ ];
2681
+ if (access.address) {
2682
+ lines.push(`Workspace address: ${access.address.hostname}`, "Publishing setup is complete. Status does not check storage or publish anything.");
2683
+ return {
2684
+ ...access,
2685
+ ...service,
2686
+ message: lines.join("\n")
2687
+ };
2688
+ }
2689
+ const next = loginNext();
2690
+ if (access.address === void 0) {
2691
+ lines.push(`Workspace address: this server does not report it. Run ${shown(next)} to confirm or choose it.`);
2692
+ return {
2693
+ ...access,
2694
+ ...service,
2695
+ message: lines.join("\n"),
2696
+ next
2697
+ };
2698
+ }
2699
+ lines.push(`Workspace address: not selected yet. Run ${shown(next)} to choose one, then publish.`);
2700
+ return {
2701
+ ...access,
2702
+ ...service,
2703
+ status: "address_required",
2704
+ message: lines.join("\n"),
2705
+ next
2706
+ };
2707
+ }
2708
+ //#endregion
2709
+ //#region src/cli.ts
2710
+ const args = process.argv.slice(2);
2711
+ const separator = args.indexOf("--");
2712
+ const flagArgs = args.slice(0, separator === -1 ? void 0 : separator);
2713
+ const parseBoolean = Schema.decodeUnknownOption(Config.Boolean);
2714
+ const booleanValue = (value) => Option.getOrUndefined(parseBoolean(value));
1410
2715
  const json = flagArgs.some((arg, index) => arg === "--json" ? booleanValue(flagArgs[index + 1]) !== false : arg.startsWith("--json=") && booleanValue(arg.slice(7)) !== false);
1411
2716
  const jsonFlag = GlobalFlag.setting("json")({ flag: Flag.boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Emit one JSON result, including errors.")) });
1412
- let outcome;
2717
+ let envelope;
2718
+ function settle(command, result) {
2719
+ const outcome = outcomes[result.status];
2720
+ envelope = {
2721
+ ok: outcome === "succeeded",
2722
+ command,
2723
+ outcome,
2724
+ ...result
2725
+ };
2726
+ process.exitCode = exitCode(result.status);
2727
+ }
1413
2728
  const plainFormatter = CliOutput.defaultFormatter({ colors: false });
1414
2729
  const jsonFormatter = {
1415
2730
  formatCliError: plainFormatter.formatCliError,
1416
2731
  formatError: plainFormatter.formatError,
1417
2732
  formatErrors: plainFormatter.formatErrors,
1418
2733
  formatHelpDoc: (doc) => {
1419
- outcome = {
1420
- ok: true,
1421
- command: "help",
2734
+ settle("help", {
2735
+ status: "help",
2736
+ message: plainFormatter.formatHelpDoc(doc),
1422
2737
  usage: doc.usage,
1423
- description: doc.description,
1424
- help: plainFormatter.formatHelpDoc(doc)
1425
- };
2738
+ description: doc.description
2739
+ });
1426
2740
  return "";
1427
2741
  },
1428
2742
  formatVersion: (name, version) => {
1429
- outcome = {
1430
- ok: true,
1431
- command: "version",
2743
+ settle("version", {
2744
+ status: "version",
2745
+ message: plainFormatter.formatVersion(name, version),
1432
2746
  name,
1433
2747
  version
1434
- };
2748
+ });
1435
2749
  return "";
1436
2750
  }
1437
2751
  };
1438
- const status = Command.make("status", { server: Flag.string("server").pipe(Flag.withDescription("Pageship server HTTP(S) base URL, for example http://localhost:8787.")) }, ({ server }) => Effect.gen(function* () {
1439
- const result = yield* Effect.tryPromise({
1440
- try: () => checkCompatibility(server),
1441
- catch: (error) => error instanceof Error ? error : new Error(String(error))
1442
- });
1443
- outcome = {
1444
- ok: result.status === "compatible",
1445
- command: "status",
1446
- ...result
1447
- };
1448
- process.exitCode = result.status === "compatible" ? 0 : 2;
1449
- if (!json) yield* Console.log(`${result.message}\nServer ${result.server.serverVersion}; current API major ${result.server.currentApiMajor}; supported API majors: ${result.server.supportedApiMajors.join(", ")}.\nThis CLI requires API major ${result.requiredApiMajor}.`);
1450
- })).pipe(Command.withDescription("Check whether this CLI can use a Pageship server."));
1451
- const serverFlag = Flag.string("server").pipe(Flag.withDescription("Pageship HTTPS origin, or localhost HTTP for development."));
2752
+ const validServer = (value) => {
2753
+ try {
2754
+ serverOrigin(value);
2755
+ return true;
2756
+ } catch {
2757
+ return false;
2758
+ }
2759
+ };
2760
+ const serverFlag = Flag.string("server").pipe(Flag.filter(validServer, () => "Use an HTTPS server origin, or HTTP on localhost for local development, without a path, credentials, query or fragment."), Flag.withDescription("Advanced: use another Pageship server instead of the hosted service. HTTPS origin, or localhost HTTP for development."), Flag.optional);
2761
+ const addressChoiceFlag = (description) => Flag.string("address-choice").pipe(Flag.filter((value) => value.length > 0 && value.length <= 100, () => "Use a choiceId returned by login."), Flag.withDescription(description), Flag.optional);
2762
+ const skillsEnvironment = {
2763
+ source: fileURLToPath(new URL("./skills/", import.meta.url)),
2764
+ version,
2765
+ home: homedir(),
2766
+ platform: process.platform,
2767
+ nodeVersion: nodeOnPath
2768
+ };
2769
+ const status = Command.make("status", { server: serverFlag }, ({ server }) => authCommand("status", (signal) => checkStatus(Option.getOrUndefined(server), signal, skillsEnvironment))).pipe(Command.withDescription("Check the service, sign-in, personal workspace, address and Pageship skills without changing anything."));
2770
+ const skills = Command.make("skills").pipe(Command.withDescription("Manage the Pageship skills that ship with this CLI."), Command.withSubcommands([Command.make("install", { force: Flag.boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Replace Pageship skill folders that were changed or not installed by this CLI.")) }, ({ force }) => authCommand("skills", (signal) => installSkills(skillsEnvironment, {
2771
+ force,
2772
+ signal
2773
+ }))).pipe(Command.withDescription("Install the Pageship skills in ~/.agents/skills, and link them in ~/.claude/skills when ~/.claude exists."))]));
1452
2774
  const login = Command.make("login", {
1453
- server: serverFlag,
1454
2775
  complete: Flag.boolean("complete").pipe(Flag.withDefault(false), Flag.withDescription("Check a pending authorization once, without waiting.")),
1455
- restart: Flag.boolean("restart").pipe(Flag.withDefault(false), Flag.withDescription("Start a new authorization code."))
1456
- }, ({ server, complete, restart }) => authCommand("login", (signal) => authorize({
1457
- server,
1458
- complete,
1459
- restart,
1460
- interactive: !json && Boolean(process.stdin.isTTY && process.stdout.isTTY) && !complete,
1461
- progress: (message) => console.log(message),
1462
- signal
1463
- }))).pipe(Command.withDescription("Authorize this installation through a browser."));
1464
- const whoami = Command.make("whoami", { server: serverFlag }, ({ server }) => authCommand("whoami", (signal) => identify(server, signal))).pipe(Command.withDescription("Identify your personal workspace using the stored credential."));
2776
+ restart: Flag.boolean("restart").pipe(Flag.withDefault(false), Flag.withDescription("Start a new authorization code.")),
2777
+ addressChoice: addressChoiceFlag("Select a server-generated workspace address choice by its ID."),
2778
+ refreshAddresses: Flag.boolean("refresh-addresses").pipe(Flag.withDefault(false), Flag.withDescription("Replace the offered workspace address choices.")),
2779
+ server: serverFlag
2780
+ }, ({ server, complete, restart, addressChoice, refreshAddresses }) => authCommand("login", (signal) => {
2781
+ const terminal = !json && Boolean(process.stdin.isTTY && process.stdout.isTTY);
2782
+ return authorize({
2783
+ server: Option.getOrUndefined(server),
2784
+ complete,
2785
+ restart,
2786
+ addressChoice: Option.getOrUndefined(addressChoice),
2787
+ refreshAddresses,
2788
+ interactive: terminal && !complete,
2789
+ addressInteractive: terminal,
2790
+ progress: (message) => console.log(message),
2791
+ signal
2792
+ });
2793
+ })).pipe(Command.withDescription("Authorize this installation through a browser and choose your workspace address."));
2794
+ const whoami = Command.make("whoami", { server: serverFlag }, ({ server }) => authCommand("whoami", (signal) => identify(Option.getOrUndefined(server), signal))).pipe(Command.withDescription("Identify your personal workspace using the stored credential."));
2795
+ const idempotencyKeyFlag = Flag.string("idempotency-key").pipe(Flag.filter((value) => /^[A-Za-z0-9_-]{8,128}$/u.test(value), () => "Use an 8-128 character idempotency key containing letters, digits, underscores or hyphens."), Flag.withDescription("Retry an uncertain operation with the key its result returned."), Flag.optional);
1465
2796
  const publishCommand = Command.make("publish", {
1466
2797
  file: Argument.string("file"),
1467
- server: serverFlag,
1468
- idempotencyKey: Flag.string("idempotency-key").pipe(Flag.filter((value) => /^[A-Za-z0-9_-]{8,128}$/u.test(value), () => "Use an 8-128 character idempotency key containing letters, digits, underscores or hyphens."), Flag.optional),
2798
+ idempotencyKey: idempotencyKeyFlag,
1469
2799
  newDocument: Flag.boolean("new").pipe(Flag.withDefault(false), Flag.withDescription("Explicitly create a separate private document, even when the source matches.")),
1470
- revise: Flag.string("revise").pipe(Flag.filter((value) => isDocumentId(value), () => "Use the six-character candidate document ID."), Flag.optional),
1471
- expectedRevision: Flag.string("expected-revision").pipe(Flag.filter((value) => isRevisionId(value), () => "Use the candidate's current revision UUID."), Flag.optional),
1472
- addressChoice: Flag.string("address-choice").pipe(Flag.optional),
1473
- refreshAddresses: Flag.boolean("refresh-addresses").pipe(Flag.withDefault(false))
1474
- }, ({ file, server, idempotencyKey, addressChoice, refreshAddresses, newDocument, revise, expectedRevision }) => authCommand("publish", (signal) => publish({
2800
+ noMarker: Flag.boolean("no-marker").pipe(Flag.withDefault(false), Flag.withDescription("Publish the file as it is, without adding or changing its document marker.")),
2801
+ revise: Flag.string("revise").pipe(Flag.filter((value) => isDocumentId(value), () => "Use the six-character candidate document ID."), Flag.withDescription("Publish a new revision of this document. Needs --expected-revision."), Flag.optional),
2802
+ expectedRevision: Flag.string("expected-revision").pipe(Flag.filter((value) => isRevisionId(value), () => "Use the candidate's current revision UUID."), Flag.withDescription("The document's current revision, as you last saw it. A newer revision stops the publish."), Flag.optional),
2803
+ addressChoice: addressChoiceFlag("No longer supported. Use login --address-choice."),
2804
+ refreshAddresses: Flag.boolean("refresh-addresses").pipe(Flag.withDefault(false), Flag.withDescription("No longer supported. Use login --refresh-addresses.")),
2805
+ server: serverFlag
2806
+ }, ({ file, server, idempotencyKey, addressChoice, refreshAddresses, newDocument, noMarker, revise, expectedRevision }) => authCommand("publish", (signal) => publish({
1475
2807
  file,
1476
- server,
2808
+ server: Option.getOrUndefined(server),
1477
2809
  newDocument,
2810
+ noMarker,
1478
2811
  revise: Option.getOrUndefined(revise),
1479
2812
  expectedRevision: Option.getOrUndefined(expectedRevision),
1480
2813
  idempotencyKey: Option.getOrUndefined(idempotencyKey),
@@ -1483,82 +2816,151 @@ const publishCommand = Command.make("publish", {
1483
2816
  interactive: !json && Boolean(process.stdin.isTTY && process.stdout.isTTY),
1484
2817
  signal
1485
2818
  }))).pipe(Command.withDescription("Publish HTML, resolve source matches, or revise a selected document."));
2819
+ const documentIdArgument = Argument.string("documentId").pipe(Argument.filter((value) => isDocumentId(value), () => "Use a six-character document ID from list."), Argument.withDescription("Six-character document ID from list."));
2820
+ const revisionFlag = (name, description) => Flag.string(name).pipe(Flag.filter((value) => isRevisionId(value), () => "Use a revision UUID from history."), Flag.withDescription(description));
2821
+ const list = Command.make("list", {
2822
+ server: serverFlag,
2823
+ cursor: Flag.string("cursor").pipe(Flag.filter((value) => isDocumentId(value), () => "Use the nextCursor document ID from a previous list."), Flag.withDescription("Continue after this nextCursor from a previous page."), Flag.optional)
2824
+ }, ({ server, cursor }) => authCommand("list", (signal) => listDocuments({
2825
+ server: Option.getOrUndefined(server),
2826
+ cursor: Option.getOrUndefined(cursor),
2827
+ signal
2828
+ }))).pipe(Command.withDescription("List your documents with their IDs, visibility and current revisions, 100 per page."));
2829
+ const history = Command.make("history", {
2830
+ documentId: documentIdArgument,
2831
+ server: serverFlag,
2832
+ cursor: revisionFlag("cursor", "Continue after this nextCursor from a previous page.").pipe(Flag.optional)
2833
+ }, ({ documentId, server, cursor }) => authCommand("history", (signal) => listRevisions({
2834
+ server: Option.getOrUndefined(server),
2835
+ documentId,
2836
+ cursor: Option.getOrUndefined(cursor),
2837
+ signal
2838
+ }))).pipe(Command.withDescription("List a document's revisions, newest first, 50 per page."));
2839
+ const download = Command.make("download", {
2840
+ documentId: documentIdArgument,
2841
+ server: serverFlag,
2842
+ output: Flag.string("output").pipe(Flag.withDescription("New file to write the exact HTML bytes to. Existing files are kept.")),
2843
+ revision: revisionFlag("revision", "Download this historical revision instead of the current one.").pipe(Flag.optional)
2844
+ }, ({ documentId, server, output, revision }) => authCommand("download", (signal) => downloadDocument({
2845
+ server: Option.getOrUndefined(server),
2846
+ documentId,
2847
+ output,
2848
+ revisionId: Option.getOrUndefined(revision),
2849
+ signal
2850
+ }))).pipe(Command.withDescription("Save a document's current or historical HTML to a new file. Visibility is unchanged."));
2851
+ const visibility = Command.make("visibility", {
2852
+ documentId: documentIdArgument,
2853
+ visibility: Argument.choice("visibility", ["private", "public"]),
2854
+ server: serverFlag
2855
+ }, ({ documentId, visibility, server }) => authCommand("visibility", (signal) => changeVisibility({
2856
+ server: Option.getOrUndefined(server),
2857
+ documentId,
2858
+ visibility,
2859
+ signal
2860
+ }))).pipe(Command.withDescription("Make a document private or public."));
2861
+ const restore = Command.make("restore", {
2862
+ documentId: documentIdArgument,
2863
+ server: serverFlag,
2864
+ revision: revisionFlag("revision", "Historical revision to restore."),
2865
+ expectedRevision: revisionFlag("expected-revision", "Current revision you inspected. A newer revision makes restore stop."),
2866
+ idempotencyKey: idempotencyKeyFlag
2867
+ }, ({ documentId, server, revision, expectedRevision, idempotencyKey }) => authCommand("restore", (signal) => restoreRevision({
2868
+ server: Option.getOrUndefined(server),
2869
+ documentId,
2870
+ revisionId: revision,
2871
+ expectedRevisionId: expectedRevision,
2872
+ idempotencyKey: Option.getOrUndefined(idempotencyKey),
2873
+ signal
2874
+ }))).pipe(Command.withDescription("Restore a historical revision's content as a new current revision."));
2875
+ /** This invocation as a continuation: its own arguments unchanged, without `--json`. */
2876
+ function sameCommand(name) {
2877
+ const rest = [];
2878
+ let named = false;
2879
+ for (let index = 0; index < args.length; index++) {
2880
+ const arg = args[index];
2881
+ if (index < flagArgs.length) {
2882
+ if (arg === "--json") {
2883
+ if (booleanValue(args[index + 1]) !== void 0) index++;
2884
+ continue;
2885
+ }
2886
+ if (arg.startsWith("--json=")) continue;
2887
+ if (!named && arg === name) {
2888
+ named = true;
2889
+ continue;
2890
+ }
2891
+ }
2892
+ rest.push(arg);
2893
+ }
2894
+ return {
2895
+ command: name,
2896
+ args: rest
2897
+ };
2898
+ }
1486
2899
  function authCommand(name, operation) {
1487
- return Effect.tryPromise({
1488
- try: async () => {
1489
- const controller = new AbortController();
1490
- const interrupt = () => controller.abort();
1491
- process.once("SIGINT", interrupt);
1492
- process.once("SIGTERM", interrupt);
1493
- try {
1494
- const result = await operation(controller.signal).catch((error) => {
1495
- if (!controller.signal.aborted) throw error;
1496
- return {
1497
- status: "interrupted",
1498
- message: "Operation interrupted. Run the command again to retry; use login --complete to resume a pending authorization."
1499
- };
1500
- });
1501
- const ok = [
1502
- "authorized",
1503
- "admitted",
1504
- "published"
1505
- ].includes(result.status);
1506
- outcome = {
1507
- ok,
1508
- command: name,
1509
- ...result
2900
+ return Effect.promise(async () => {
2901
+ const controller = new AbortController();
2902
+ const interrupt = () => controller.abort();
2903
+ process.once("SIGINT", interrupt);
2904
+ process.once("SIGTERM", interrupt);
2905
+ try {
2906
+ let thrown = false;
2907
+ const result = await operation(controller.signal).catch((error) => {
2908
+ if (controller.signal.aborted) return {
2909
+ status: "interrupted",
2910
+ message: "Operation interrupted. Run the same command again to retry or resume.",
2911
+ next: sameCommand(name)
1510
2912
  };
1511
- process.exitCode = ok ? 0 : result.status === "interrupted" ? 130 : [
1512
- "request_failed",
1513
- "invalid_file",
1514
- "invalid_action"
1515
- ].includes(result.status) ? 1 : 2;
1516
- if (!json) {
1517
- console.log(result.message);
1518
- const pending = result;
1519
- if (typeof pending.verificationUriComplete === "string" && typeof pending.userCode === "string") console.log(`Open ${pending.verificationUriComplete}\nCheck code ${pending.userCode}.\nThen run: pageship login --server ${JSON.stringify(pending.next && pending.next.args[1])} --complete`);
1520
- }
1521
- } finally {
1522
- process.removeListener("SIGINT", interrupt);
1523
- process.removeListener("SIGTERM", interrupt);
2913
+ thrown = true;
2914
+ return {
2915
+ status: "request_failed",
2916
+ message: error instanceof Error ? error.message : String(error)
2917
+ };
2918
+ });
2919
+ settle(name, result);
2920
+ if (json) return;
2921
+ if (thrown) {
2922
+ console.error(`${name === "status" ? "Could not check status" : name === "skills" ? "Could not install the Pageship skills" : "Pageship request failed"}: ${result.message}`);
2923
+ return;
1524
2924
  }
1525
- },
1526
- catch: (error) => error instanceof Error ? error : new Error(String(error))
2925
+ console.log(result.message);
2926
+ if (typeof result.verificationUriComplete === "string" && typeof result.userCode === "string" && result.next) console.log(`Open ${result.verificationUriComplete}\nCheck code ${result.userCode}.\nThen run: ${commandLine(result.next.command, result.next.args)}`);
2927
+ } finally {
2928
+ process.removeListener("SIGINT", interrupt);
2929
+ process.removeListener("SIGTERM", interrupt);
2930
+ }
1527
2931
  });
1528
2932
  }
1529
- const command = Command.make("pageship").pipe(Command.withDescription("Pageship publishes private HTML documents.\n\nEarly development: check server API compatibility with the status command. Authorize this installation with login, then publish one HTML file with publish."), Command.withSubcommands([
2933
+ const command = Command.make("pageship").pipe(Command.withDescription("Pageship publishes private HTML documents on the hosted Pageship service.\n\nEarly development: check whether this installation is ready with the status command. Authorize this installation with login, then publish one HTML file with publish. Manage published documents with list, history, download, visibility and restore. Install the Pageship skills for your agents with skills install."), Command.withSubcommands([
1530
2934
  status,
1531
2935
  login,
1532
2936
  whoami,
1533
- publishCommand
2937
+ publishCommand,
2938
+ list,
2939
+ history,
2940
+ download,
2941
+ visibility,
2942
+ restore,
2943
+ skills
1534
2944
  ]), Command.withGlobalFlags([jsonFlag]));
1535
2945
  const effectiveArgs = args.every((arg, index) => arg === "--json" || arg.startsWith("--json=") && booleanValue(arg.slice(7)) !== void 0 || index > 0 && args[index - 1] === "--json" && booleanValue(arg) !== void 0) ? [...args, "--help"] : args;
1536
2946
  const program = Command.runWith(command, {
1537
2947
  version,
1538
2948
  renderErrors: !json
1539
2949
  })(effectiveArgs).pipe(Effect.catch((error) => Effect.sync(() => {
1540
- process.exitCode = 1;
1541
- const usage = CliError.isCliError(error);
1542
- const message = usage && error._tag === "ShowHelp" ? error.errors.map((entry) => entry.message).join("\n") : error.message;
1543
- outcome = {
1544
- ok: false,
1545
- error: {
1546
- code: usage ? "usage_error" : "request_failed",
1547
- message
1548
- }
1549
- };
1550
- if (!json && !usage) console.error(`${flagArgs.includes("status") ? "Could not check server compatibility" : "Pageship request failed"}: ${message}`);
1551
- if (!json && usage && error._tag !== "ShowHelp" && error._tag !== "UserError") console.error(message);
2950
+ const help = error._tag === "ShowHelp" ? error : void 0;
2951
+ const subcommand = help?.commandPath[1];
2952
+ const message = !help ? error.message : help.errors.length > 0 ? help.errors.map((entry) => entry.message).join("\n") : `Missing subcommand. Run ${help.commandPath.join(" ")} --help to list them.`;
2953
+ settle(isCommandName(subcommand) ? subcommand : null, {
2954
+ status: "usage_error",
2955
+ message
2956
+ });
2957
+ if (!json && !help && error._tag !== "UserError") console.error(message);
1552
2958
  })), Effect.catchCause((cause) => Effect.sync(() => {
1553
- process.exitCode = 1;
1554
2959
  const message = Cause.pretty(cause);
1555
- outcome = {
1556
- ok: false,
1557
- error: {
1558
- code: "internal_error",
1559
- message
1560
- }
1561
- };
2960
+ settle(null, {
2961
+ status: "internal_error",
2962
+ message
2963
+ });
1562
2964
  if (!json) console.error(message);
1563
2965
  })), Effect.provide(NodeServices.layer));
1564
2966
  await Effect.runPromise(json ? program.pipe(Effect.provideService(CliOutput.Formatter, jsonFormatter), Effect.provideService(Console.Console, {
@@ -1566,6 +2968,12 @@ await Effect.runPromise(json ? program.pipe(Effect.provideService(CliOutput.Form
1566
2968
  log: () => {},
1567
2969
  error: () => {}
1568
2970
  }), Effect.provide(CliConfig.layer({ builtIns: [GlobalFlag.Help, GlobalFlag.Version] }))) : program);
1569
- if (json) console.log(JSON.stringify(outcome));
2971
+ if (json) {
2972
+ if (!envelope) settle(null, {
2973
+ status: "internal_error",
2974
+ message: "The command produced no result."
2975
+ });
2976
+ console.log(JSON.stringify(envelope));
2977
+ }
1570
2978
  //#endregion
1571
2979
  export {};