pageship 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -107
- package/dist/cli.mjs +1886 -505
- package/dist/skills/LICENSE +21 -0
- package/dist/skills/pageship/SKILL.md +150 -0
- package/dist/skills/pageship/references/download.md +9 -0
- package/dist/skills/pageship/references/list-and-history.md +15 -0
- package/dist/skills/pageship/references/restore.md +9 -0
- package/dist/skills/pageship/references/visibility.md +12 -0
- package/dist/skills/pageship-chart/SKILL.md +110 -0
- package/dist/skills/pageship-code-block/SKILL.md +113 -0
- package/dist/skills/pageship-code-block/references/highlight-lock.json +542 -0
- package/dist/skills/pageship-code-block/references/highlight.mjs +138 -0
- package/dist/skills/pageship-decision-record/SKILL.md +43 -0
- package/dist/skills/pageship-diagram/SKILL.md +92 -0
- package/dist/skills/pageship-diagram/references/check-diagram.js +301 -0
- package/dist/skills/pageship-diagram/references/computed-layout.md +49 -0
- package/dist/skills/pageship-diagram/references/layout.mjs +310 -0
- package/dist/skills/pageship-diagram/references/mermaid.md +89 -0
- package/dist/skills/pageship-diagram/references/svg.md +205 -0
- package/dist/skills/pageship-docs/NOTICES.md +35 -0
- package/dist/skills/pageship-docs/SKILL.md +67 -0
- package/dist/skills/pageship-docs/references/ask.md +18 -0
- package/dist/skills/pageship-docs/references/check.md +39 -0
- package/dist/skills/pageship-docs/references/content.md +29 -0
- package/dist/skills/pageship-docs/references/design.md +60 -0
- package/dist/skills/pageship-docs/references/file.md +52 -0
- package/dist/skills/pageship-docs/references/plain-default.md +331 -0
- package/dist/skills/pageship-explainer/SKILL.md +92 -0
- package/dist/skills/pageship-interaction/SKILL.md +115 -0
- package/dist/skills/pageship-interaction/references/pointer.md +271 -0
- package/dist/skills/pageship-interaction/references/step-through.md +146 -0
- package/dist/skills/pageship-key-figures/SKILL.md +127 -0
- package/dist/skills/pageship-media/SKILL.md +122 -0
- package/dist/skills/pageship-motion/SKILL.md +58 -0
- package/dist/skills/pageship-plan/SKILL.md +55 -0
- package/dist/skills/pageship-report/SKILL.md +29 -0
- package/dist/skills/pageship-review-report/SKILL.md +90 -0
- package/dist/skills/pageship-slide-deck/SKILL.md +53 -0
- package/dist/skills/pageship-slide-deck/references/engine.md +362 -0
- package/dist/skills/pageship-spec/SKILL.md +58 -0
- package/dist/skills/pageship-table/SKILL.md +243 -0
- package/dist/skills/pageship-table/references/sort-and-filter.md +235 -0
- 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 {
|
|
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 {
|
|
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
|
-
|
|
666
|
-
if (data?.status === "
|
|
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 isProject(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) &&
|
|
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.project === null || isProject(value.project)) && (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.
|
|
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
|
-
|
|
926
|
+
project: input.project,
|
|
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") && 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) {
|
|
@@ -829,39 +1009,51 @@ async function checkCompatibility(baseUrl, options = {}) {
|
|
|
829
1009
|
return evaluateCompatibility(validateCompatibility(data));
|
|
830
1010
|
}
|
|
831
1011
|
//#endregion
|
|
832
|
-
//#region src/
|
|
833
|
-
/**
|
|
834
|
-
function
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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.");
|
|
852
1045
|
});
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1046
|
+
return await store.read();
|
|
1047
|
+
} finally {
|
|
1048
|
+
await store.close();
|
|
1049
|
+
}
|
|
856
1050
|
}
|
|
857
|
-
//#endregion
|
|
858
|
-
//#region src/credential-store.ts
|
|
859
1051
|
/** Separate files bind secrets to an exact server origin, outside project directories. */
|
|
860
|
-
async function credentialStore(server, readOnly = false) {
|
|
1052
|
+
async function credentialStore(server, readOnly = false, { create = true } = {}) {
|
|
861
1053
|
const windows = process.platform === "win32";
|
|
862
|
-
const directory =
|
|
1054
|
+
const directory = configDirectory();
|
|
863
1055
|
if (!isAbsolute(directory)) throw new Error("PAGESHIP_CONFIG_DIR must be an absolute private directory.");
|
|
864
|
-
await mkdir(directory, {
|
|
1056
|
+
if (create) await mkdir(directory, {
|
|
865
1057
|
recursive: true,
|
|
866
1058
|
mode: 448
|
|
867
1059
|
});
|
|
@@ -876,6 +1068,9 @@ async function credentialStore(server, readOnly = false) {
|
|
|
876
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.`);
|
|
877
1069
|
}
|
|
878
1070
|
return {
|
|
1071
|
+
/** The verified private directory and this origin's file stem, shared with the publish manifest. */
|
|
1072
|
+
directory,
|
|
1073
|
+
name,
|
|
879
1074
|
async read() {
|
|
880
1075
|
if (windows) try {
|
|
881
1076
|
if (!(await lstat(path)).isFile()) throw new Error("Credential file must not be a link.");
|
|
@@ -887,7 +1082,7 @@ async function credentialStore(server, readOnly = false) {
|
|
|
887
1082
|
file = await open(path, constants.O_RDONLY | (windows ? 0 : constants.O_NOFOLLOW));
|
|
888
1083
|
} catch (error) {
|
|
889
1084
|
if (error.code === "ENOENT") return {};
|
|
890
|
-
throw new Error("Could not open
|
|
1085
|
+
throw new Error("Could not open credentials. Check the credential file is not a symlink.");
|
|
891
1086
|
}
|
|
892
1087
|
try {
|
|
893
1088
|
const stat = await file.stat();
|
|
@@ -895,9 +1090,9 @@ async function credentialStore(server, readOnly = false) {
|
|
|
895
1090
|
const raw = await file.readFile("utf8");
|
|
896
1091
|
let value;
|
|
897
1092
|
try {
|
|
898
|
-
value = JSON.parse(
|
|
1093
|
+
value = JSON.parse(raw);
|
|
899
1094
|
} catch {
|
|
900
|
-
throw new Error("Could not read
|
|
1095
|
+
throw new Error("Could not read credential JSON. Choose a new private PAGESHIP_CONFIG_DIR and log in again.");
|
|
901
1096
|
}
|
|
902
1097
|
if (!value || typeof value !== "object") throw new Error("Invalid credential file. Start again with a new private PAGESHIP_CONFIG_DIR.");
|
|
903
1098
|
const candidate = value;
|
|
@@ -911,20 +1106,7 @@ async function credentialStore(server, readOnly = false) {
|
|
|
911
1106
|
},
|
|
912
1107
|
async write(value) {
|
|
913
1108
|
if (readOnly) throw new Error("Cannot update credentials through a read-only store.");
|
|
914
|
-
|
|
915
|
-
try {
|
|
916
|
-
const file = await open(temporary, "wx", 384);
|
|
917
|
-
try {
|
|
918
|
-
const serialized = JSON.stringify(value);
|
|
919
|
-
await file.writeFile(windows ? await windowsProtection(serialized, "protect") : serialized);
|
|
920
|
-
await file.sync();
|
|
921
|
-
} finally {
|
|
922
|
-
await file.close();
|
|
923
|
-
}
|
|
924
|
-
await rename(temporary, path);
|
|
925
|
-
} finally {
|
|
926
|
-
await rm(temporary, { force: true });
|
|
927
|
-
}
|
|
1109
|
+
await replaceFile(path, JSON.stringify(value));
|
|
928
1110
|
},
|
|
929
1111
|
close: async () => {
|
|
930
1112
|
if (!readOnly) await rm(lock, {
|
|
@@ -935,7 +1117,267 @@ async function credentialStore(server, readOnly = false) {
|
|
|
935
1117
|
};
|
|
936
1118
|
}
|
|
937
1119
|
//#endregion
|
|
938
|
-
//#region src/
|
|
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
|
+
project_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";
|
|
939
1381
|
function serverOrigin(value) {
|
|
940
1382
|
const url = new URL(value);
|
|
941
1383
|
if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || !(url.protocol === "https:" || url.protocol === "http:" && [
|
|
@@ -945,258 +1387,102 @@ function serverOrigin(value) {
|
|
|
945
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.");
|
|
946
1388
|
return url.origin;
|
|
947
1389
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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.2.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 project;
|
|
960
1428
|
try {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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 repository = `${url.hostname.toLowerCase()}${url.port ? `:${url.port}` : ""}/${url.pathname.replace(/^\/+|\/+$/gu, "").replace(/\.git$/u, "")}`;
|
|
1449
|
+
if (isProject(repository)) project = repository;
|
|
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
|
+
project
|
|
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();
|
|
964
1480
|
}
|
|
965
|
-
if (!value || typeof value !== "object") throw new Error("Invalid authorization response. Check the server and retry.");
|
|
966
|
-
const data = value;
|
|
967
|
-
if (!response.ok && typeof data.error !== "string") throw new Error(`Authorization server returned HTTP ${response.status}. Retry later.`);
|
|
968
|
-
return data;
|
|
969
1481
|
}
|
|
970
|
-
function
|
|
1482
|
+
function invalidFile(error) {
|
|
971
1483
|
return {
|
|
972
|
-
status: "
|
|
973
|
-
message:
|
|
974
|
-
verificationUri: pending.verificationUri,
|
|
975
|
-
verificationUriComplete: pending.verificationUriComplete,
|
|
976
|
-
userCode: pending.userCode,
|
|
977
|
-
expiresAt: new Date(pending.expiresAt).toISOString(),
|
|
978
|
-
retryAfterSeconds: Math.max(0, Math.ceil((pending.nextPollAt - Date.now()) / 1e3)),
|
|
979
|
-
next: {
|
|
980
|
-
command: "login",
|
|
981
|
-
args: [
|
|
982
|
-
"--server",
|
|
983
|
-
server,
|
|
984
|
-
"--complete"
|
|
985
|
-
]
|
|
986
|
-
}
|
|
987
|
-
};
|
|
988
|
-
}
|
|
989
|
-
async function authorize(options) {
|
|
990
|
-
const server = serverOrigin(options.server);
|
|
991
|
-
const compatibility = await checkCompatibility(server, { signal: options.signal });
|
|
992
|
-
if (compatibility.status !== "compatible") return compatibility;
|
|
993
|
-
const store = await credentialStore(server);
|
|
994
|
-
try {
|
|
995
|
-
const credentials = await store.read();
|
|
996
|
-
if (credentials.token && !options.restart && !credentials.pending) {
|
|
997
|
-
const access = await getWorkspace(server, {
|
|
998
|
-
token: credentials.token,
|
|
999
|
-
signal: options.signal
|
|
1000
|
-
});
|
|
1001
|
-
if (access.status === "admitted") return {
|
|
1002
|
-
...access,
|
|
1003
|
-
status: "authorized",
|
|
1004
|
-
message: `Authorized as ${access.user.name}.`
|
|
1005
|
-
};
|
|
1006
|
-
if (access.status === "uninvited") return {
|
|
1007
|
-
status: "uninvited",
|
|
1008
|
-
message: "This account is no longer invited. Contact the server owner or use login --restart for another account."
|
|
1009
|
-
};
|
|
1010
|
-
delete credentials.token;
|
|
1011
|
-
await store.write(credentials);
|
|
1012
|
-
}
|
|
1013
|
-
if (options.restart) delete credentials.pending;
|
|
1014
|
-
if (!credentials.pending) {
|
|
1015
|
-
if (options.complete) return {
|
|
1016
|
-
status: "invalid_attempt",
|
|
1017
|
-
message: "No pending authorization. Run login without --complete to start a new attempt."
|
|
1018
|
-
};
|
|
1019
|
-
const data = await authRequest(server, "/device/code", { client_id: "pageship-cli" }, options.signal);
|
|
1020
|
-
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.");
|
|
1021
|
-
for (const uri of [data.verification_uri, data.verification_uri_complete]) {
|
|
1022
|
-
const parsed = new URL(uri);
|
|
1023
|
-
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.");
|
|
1024
|
-
}
|
|
1025
|
-
credentials.pending = {
|
|
1026
|
-
deviceCode: data.device_code,
|
|
1027
|
-
userCode: data.user_code,
|
|
1028
|
-
verificationUri: data.verification_uri,
|
|
1029
|
-
verificationUriComplete: data.verification_uri_complete,
|
|
1030
|
-
expiresAt: Date.now() + data.expires_in * 1e3,
|
|
1031
|
-
interval: data.interval,
|
|
1032
|
-
nextPollAt: 0
|
|
1033
|
-
};
|
|
1034
|
-
await store.write(credentials);
|
|
1035
|
-
}
|
|
1036
|
-
const pending = credentials.pending;
|
|
1037
|
-
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.`);
|
|
1038
|
-
do {
|
|
1039
|
-
if (options.signal.aborted) return {
|
|
1040
|
-
status: "interrupted",
|
|
1041
|
-
message: "Authorization interrupted. Run login --complete to resume, or login --restart for a new code."
|
|
1042
|
-
};
|
|
1043
|
-
if (Date.now() >= pending.expiresAt) {
|
|
1044
|
-
delete credentials.pending;
|
|
1045
|
-
await store.write(credentials);
|
|
1046
|
-
return {
|
|
1047
|
-
status: "expired",
|
|
1048
|
-
message: "The authorization code expired. Run login again for a new code."
|
|
1049
|
-
};
|
|
1050
|
-
}
|
|
1051
|
-
if (!options.interactive && (!options.complete || Date.now() < pending.nextPollAt)) return pendingResult(pending, server);
|
|
1052
|
-
if (Date.now() < pending.nextPollAt) await setTimeout$1(pending.nextPollAt - Date.now(), void 0, { signal: options.signal });
|
|
1053
|
-
pending.nextPollAt = Date.now() + pending.interval * 1e3;
|
|
1054
|
-
await store.write(credentials);
|
|
1055
|
-
const data = await authRequest(server, "/device/token", {
|
|
1056
|
-
client_id: "pageship-cli",
|
|
1057
|
-
device_code: pending.deviceCode,
|
|
1058
|
-
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
1059
|
-
}, options.signal);
|
|
1060
|
-
if (typeof data.access_token === "string" && data.access_token.length > 0) {
|
|
1061
|
-
credentials.token = data.access_token;
|
|
1062
|
-
delete credentials.pending;
|
|
1063
|
-
await store.write(credentials);
|
|
1064
|
-
const access = await getWorkspace(server, {
|
|
1065
|
-
token: credentials.token,
|
|
1066
|
-
signal: options.signal
|
|
1067
|
-
});
|
|
1068
|
-
if (access.status !== "admitted") return {
|
|
1069
|
-
status: access.status,
|
|
1070
|
-
message: "Authorization finished, but workspace access is unavailable. Check your admission with the server owner."
|
|
1071
|
-
};
|
|
1072
|
-
return {
|
|
1073
|
-
...access,
|
|
1074
|
-
status: "authorized",
|
|
1075
|
-
message: `Authorized as ${access.user.name}. Your credential is stored privately for this server.`
|
|
1076
|
-
};
|
|
1077
|
-
}
|
|
1078
|
-
if (data.error === "slow_down") {
|
|
1079
|
-
pending.interval += 5;
|
|
1080
|
-
pending.nextPollAt = Date.now() + pending.interval * 1e3;
|
|
1081
|
-
await store.write(credentials);
|
|
1082
|
-
} else if (data.error !== "authorization_pending") {
|
|
1083
|
-
delete credentials.pending;
|
|
1084
|
-
await store.write(credentials);
|
|
1085
|
-
const status = data.error === "access_denied" ? "denied" : data.error === "expired_token" ? "expired" : "invalid_attempt";
|
|
1086
|
-
return {
|
|
1087
|
-
status,
|
|
1088
|
-
message: `Authorization ${status.replaceAll("_", " ")}. Run login again for a new code.`
|
|
1089
|
-
};
|
|
1090
|
-
}
|
|
1091
|
-
if (!options.interactive) return pendingResult(pending, server);
|
|
1092
|
-
} while (!options.signal.aborted);
|
|
1093
|
-
return {
|
|
1094
|
-
status: "interrupted",
|
|
1095
|
-
message: "Authorization interrupted. Run login --complete to resume."
|
|
1096
|
-
};
|
|
1097
|
-
} catch (error) {
|
|
1098
|
-
if (options.signal.aborted) return {
|
|
1099
|
-
status: "interrupted",
|
|
1100
|
-
message: "Authorization interrupted. Run login --complete to resume or login --restart to start again."
|
|
1101
|
-
};
|
|
1102
|
-
throw error;
|
|
1103
|
-
} finally {
|
|
1104
|
-
await store.close();
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
async function identify(serverValue, signal) {
|
|
1108
|
-
const server = serverOrigin(serverValue);
|
|
1109
|
-
const compatibility = await checkCompatibility(server, { signal });
|
|
1110
|
-
if (compatibility.status !== "compatible") return compatibility;
|
|
1111
|
-
const store = await credentialStore(server, true);
|
|
1112
|
-
try {
|
|
1113
|
-
const { token } = await store.read();
|
|
1114
|
-
if (!token) return {
|
|
1115
|
-
status: "signed_out",
|
|
1116
|
-
message: "This installation is not authorized. Run login --server with this server to begin."
|
|
1117
|
-
};
|
|
1118
|
-
const access = await getWorkspace(server, {
|
|
1119
|
-
token,
|
|
1120
|
-
signal
|
|
1121
|
-
});
|
|
1122
|
-
return {
|
|
1123
|
-
...access,
|
|
1124
|
-
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."
|
|
1125
|
-
};
|
|
1126
|
-
} finally {
|
|
1127
|
-
await store.close();
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
//#endregion
|
|
1131
|
-
//#region src/publish.ts
|
|
1132
|
-
const execute = promisify(execFile);
|
|
1133
|
-
const contained = (root, path) => {
|
|
1134
|
-
const part = relative(root, path);
|
|
1135
|
-
return part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part);
|
|
1136
|
-
};
|
|
1137
|
-
async function sourceMetadata(file) {
|
|
1138
|
-
const path = await realpath(resolve(file));
|
|
1139
|
-
const cwd = await realpath(process.cwd());
|
|
1140
|
-
let root = contained(cwd, path) ? cwd : dirname(path);
|
|
1141
|
-
let sourceContext = "personal";
|
|
1142
|
-
try {
|
|
1143
|
-
const git = async (...args) => (await execute("git", [
|
|
1144
|
-
"-C",
|
|
1145
|
-
dirname(path),
|
|
1146
|
-
...args
|
|
1147
|
-
], {
|
|
1148
|
-
timeout: 5e3,
|
|
1149
|
-
maxBuffer: 16384
|
|
1150
|
-
})).stdout.trim();
|
|
1151
|
-
const gitRoot = await realpath(await git("rev-parse", "--show-toplevel"));
|
|
1152
|
-
if (contained(gitRoot, path)) {
|
|
1153
|
-
root = gitRoot;
|
|
1154
|
-
const remote = await git("remote", "get-url", "origin").catch(() => "");
|
|
1155
|
-
const url = remote.includes("://") ? new URL(remote) : /^[^/]+@[^:]+:.+/u.test(remote) ? new URL(`ssh://${remote.replace(":", "/")}`) : void 0;
|
|
1156
|
-
if (url && [
|
|
1157
|
-
"https:",
|
|
1158
|
-
"http:",
|
|
1159
|
-
"ssh:",
|
|
1160
|
-
"git:"
|
|
1161
|
-
].includes(url.protocol) && url.hostname) {
|
|
1162
|
-
const repository = `${url.hostname.toLowerCase()}${url.port ? `:${url.port}` : ""}/${url.pathname.replace(/^\/+|\/+$/gu, "").replace(/\.git$/u, "")}`;
|
|
1163
|
-
sourceContext = `git:${createHash("sha256").update(repository).digest("hex")}`;
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
} catch {}
|
|
1167
|
-
const sourcePath = relative(root, path).split(sep).join("/");
|
|
1168
|
-
if (!/\.html?$/iu.test(sourcePath)) throw new Error("Publish one .html or .htm file.");
|
|
1169
|
-
return {
|
|
1170
|
-
path,
|
|
1171
|
-
sourcePath,
|
|
1172
|
-
sourceContext
|
|
1173
|
-
};
|
|
1174
|
-
}
|
|
1175
|
-
async function fileBytes(path, limit) {
|
|
1176
|
-
const file = await open(path, "r");
|
|
1177
|
-
try {
|
|
1178
|
-
const stat = await file.stat();
|
|
1179
|
-
if (!stat.isFile()) throw new Error("Publish one regular HTML file.");
|
|
1180
|
-
if (stat.size > limit) return;
|
|
1181
|
-
if (stat.size === 0) throw new Error("The HTML file is empty.");
|
|
1182
|
-
const chunks = [];
|
|
1183
|
-
let length = 0;
|
|
1184
|
-
while (length <= limit) {
|
|
1185
|
-
const chunk = Buffer.alloc(Math.min(65536, limit + 1 - length));
|
|
1186
|
-
const { bytesRead } = await file.read(chunk);
|
|
1187
|
-
if (!bytesRead) break;
|
|
1188
|
-
chunks.push(chunk.subarray(0, bytesRead));
|
|
1189
|
-
length += bytesRead;
|
|
1190
|
-
}
|
|
1191
|
-
return length > limit ? void 0 : Buffer.concat(chunks);
|
|
1192
|
-
} finally {
|
|
1193
|
-
await file.close();
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
function invalidFile(error) {
|
|
1197
|
-
return {
|
|
1198
|
-
status: "invalid_file",
|
|
1199
|
-
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."}`
|
|
1200
1486
|
};
|
|
1201
1487
|
}
|
|
1202
1488
|
function actionArgs(action) {
|
|
@@ -1207,13 +1493,10 @@ function actionArgs(action) {
|
|
|
1207
1493
|
action.expectedRevisionId
|
|
1208
1494
|
] : [];
|
|
1209
1495
|
}
|
|
1210
|
-
|
|
1496
|
+
/** `contextArgs` are the server and marker arguments every follow-up keeps. */
|
|
1497
|
+
function resolutions(file, contextArgs, candidates) {
|
|
1211
1498
|
const command = "publish";
|
|
1212
|
-
const args = [
|
|
1213
|
-
file,
|
|
1214
|
-
"--server",
|
|
1215
|
-
server
|
|
1216
|
-
];
|
|
1499
|
+
const args = [file, ...contextArgs];
|
|
1217
1500
|
return {
|
|
1218
1501
|
new: {
|
|
1219
1502
|
command,
|
|
@@ -1278,8 +1561,26 @@ async function publish(options) {
|
|
|
1278
1561
|
documentId: options.revise,
|
|
1279
1562
|
expectedRevisionId: options.expectedRevision
|
|
1280
1563
|
} : {};
|
|
1281
|
-
const
|
|
1282
|
-
const
|
|
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;
|
|
1283
1584
|
let source;
|
|
1284
1585
|
try {
|
|
1285
1586
|
source = await sourceMetadata(options.file);
|
|
@@ -1287,13 +1588,17 @@ async function publish(options) {
|
|
|
1287
1588
|
return invalidFile(error);
|
|
1288
1589
|
}
|
|
1289
1590
|
const compatibility = await checkCompatibility(server, { signal: options.signal });
|
|
1290
|
-
if (compatibility.status !== "compatible") return
|
|
1591
|
+
if (compatibility.status !== "compatible") return {
|
|
1592
|
+
...compatibility,
|
|
1593
|
+
status: compatibility.status
|
|
1594
|
+
};
|
|
1291
1595
|
const store = await credentialStore(server, true);
|
|
1292
1596
|
try {
|
|
1293
1597
|
const { token } = await store.read();
|
|
1294
1598
|
if (!token) return {
|
|
1295
1599
|
status: "signed_out",
|
|
1296
|
-
message:
|
|
1600
|
+
message: `This installation is not authorized. Run ${commandLine("login", selection.args)} first.`,
|
|
1601
|
+
next: login
|
|
1297
1602
|
};
|
|
1298
1603
|
const request = {
|
|
1299
1604
|
token,
|
|
@@ -1311,124 +1616,1097 @@ async function publish(options) {
|
|
|
1311
1616
|
maxUploadBytes,
|
|
1312
1617
|
message: `The HTML file exceeds the ${maxUploadBytes}-byte limit. No HTML was uploaded.`
|
|
1313
1618
|
};
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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;
|
|
1324
1635
|
});
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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
|
+
}
|
|
1334
1647
|
};
|
|
1335
|
-
const
|
|
1336
|
-
if (
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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.project === (source.project ?? null)) {
|
|
1657
|
+
action = {
|
|
1658
|
+
action: "revise",
|
|
1659
|
+
documentId: target.id,
|
|
1660
|
+
expectedRevisionId: target.revisionId
|
|
1661
|
+
};
|
|
1662
|
+
automatic = "marker";
|
|
1663
|
+
} else return {
|
|
1664
|
+
status: "project_mismatch",
|
|
1665
|
+
document: target,
|
|
1666
|
+
message: `This file's marker names ${target.url}, a document from ${target.project ?? "a loose file"}, but this file ${source.project ? `belongs to ${source.project}` : "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
|
+
}
|
|
1341
1680
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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.project ? { project: source.project } : {},
|
|
1695
|
+
cliVersion: version,
|
|
1696
|
+
idempotencyKey,
|
|
1697
|
+
...proposal
|
|
1698
|
+
}, request);
|
|
1699
|
+
if (result.status !== "document_id_taken" || tries === 8) return result;
|
|
1700
|
+
idempotencyKey = publicationKey();
|
|
1354
1701
|
}
|
|
1355
1702
|
};
|
|
1356
|
-
if (address.status !== "allocated") return {
|
|
1357
|
-
...address,
|
|
1358
|
-
message: "Workspace address is unavailable. Sign in again or check admission."
|
|
1359
|
-
};
|
|
1360
|
-
const send = () => publishDocument(server, {
|
|
1361
|
-
bytes,
|
|
1362
|
-
sourcePath: source.sourcePath,
|
|
1363
|
-
sourceContext: source.sourceContext,
|
|
1364
|
-
idempotencyKey,
|
|
1365
|
-
...action
|
|
1366
|
-
}, request);
|
|
1367
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";
|
|
1368
1711
|
if (result.status === "source_collision" && options.interactive) {
|
|
1369
1712
|
const selected = await choosePublication(result.candidates, options.signal);
|
|
1370
1713
|
if (!selected) return {
|
|
1371
1714
|
status: "cancelled",
|
|
1372
|
-
message: "Publication cancelled. No document changed."
|
|
1715
|
+
message: "Publication cancelled. No document changed.",
|
|
1716
|
+
next: {
|
|
1717
|
+
command: "publish",
|
|
1718
|
+
args: [options.file, ...contextArgs]
|
|
1719
|
+
}
|
|
1373
1720
|
};
|
|
1374
1721
|
action = selected;
|
|
1375
1722
|
result = await send();
|
|
1376
1723
|
}
|
|
1377
|
-
|
|
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.`;
|
|
1378
1750
|
return {
|
|
1379
1751
|
...result,
|
|
1752
|
+
...result.status === "published" ? { change } : {},
|
|
1380
1753
|
idempotencyKey,
|
|
1381
1754
|
message,
|
|
1382
|
-
...result.status === "source_collision" ? { resolutions: resolutions(options.file,
|
|
1383
|
-
...result.status === "revision_conflict" ? { resolutions: resolutions(options.file,
|
|
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 } : {}
|
|
1384
1758
|
};
|
|
1385
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.";
|
|
1386
1770
|
if (options.signal.aborted) return {
|
|
1387
1771
|
status: "interrupted",
|
|
1388
1772
|
idempotencyKey,
|
|
1389
|
-
message: `Publication interrupted.
|
|
1390
|
-
next
|
|
1391
|
-
command: "publish",
|
|
1392
|
-
args: [
|
|
1393
|
-
options.file,
|
|
1394
|
-
"--server",
|
|
1395
|
-
server,
|
|
1396
|
-
"--idempotency-key",
|
|
1397
|
-
idempotencyKey,
|
|
1398
|
-
...actionArgs(action)
|
|
1399
|
-
]
|
|
1400
|
-
}
|
|
1773
|
+
message: `Publication interrupted. ${guidance}`,
|
|
1774
|
+
next
|
|
1401
1775
|
};
|
|
1402
|
-
if (error instanceof DocumentRequestError) return {
|
|
1776
|
+
if (error instanceof DocumentRequestError && isStatus(error.status)) return {
|
|
1403
1777
|
status: error.status,
|
|
1404
|
-
message: error.message,
|
|
1405
|
-
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 } : {}
|
|
1406
1781
|
};
|
|
1407
1782
|
return {
|
|
1408
1783
|
status: "request_failed",
|
|
1409
1784
|
idempotencyKey,
|
|
1410
|
-
message: `Publication did not finish.
|
|
1411
|
-
next
|
|
1412
|
-
command: "publish",
|
|
1413
|
-
args: [
|
|
1414
|
-
options.file,
|
|
1415
|
-
"--server",
|
|
1416
|
-
server,
|
|
1417
|
-
"--idempotency-key",
|
|
1418
|
-
idempotencyKey,
|
|
1419
|
-
...actionArgs(action)
|
|
1420
|
-
]
|
|
1421
|
-
}
|
|
1785
|
+
message: `Publication did not finish. ${guidance} ${error instanceof Error ? error.message : "Check the server and file."}`,
|
|
1786
|
+
next
|
|
1422
1787
|
};
|
|
1423
1788
|
} finally {
|
|
1424
1789
|
await store.close();
|
|
1425
1790
|
}
|
|
1426
1791
|
}
|
|
1427
1792
|
//#endregion
|
|
1428
|
-
//#region
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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.project ? ` project ${document.project}\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";
|
|
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 at ${setupGuide} covers them.`;
|
|
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
|
|
1432
2710
|
const args = process.argv.slice(2);
|
|
1433
2711
|
const separator = args.indexOf("--");
|
|
1434
2712
|
const flagArgs = args.slice(0, separator === -1 ? void 0 : separator);
|
|
@@ -1436,72 +2714,100 @@ const parseBoolean = Schema.decodeUnknownOption(Config.Boolean);
|
|
|
1436
2714
|
const booleanValue = (value) => Option.getOrUndefined(parseBoolean(value));
|
|
1437
2715
|
const json = flagArgs.some((arg, index) => arg === "--json" ? booleanValue(flagArgs[index + 1]) !== false : arg.startsWith("--json=") && booleanValue(arg.slice(7)) !== false);
|
|
1438
2716
|
const jsonFlag = GlobalFlag.setting("json")({ flag: Flag.boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Emit one JSON result, including errors.")) });
|
|
1439
|
-
let
|
|
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
|
+
}
|
|
1440
2728
|
const plainFormatter = CliOutput.defaultFormatter({ colors: false });
|
|
1441
2729
|
const jsonFormatter = {
|
|
1442
2730
|
formatCliError: plainFormatter.formatCliError,
|
|
1443
2731
|
formatError: plainFormatter.formatError,
|
|
1444
2732
|
formatErrors: plainFormatter.formatErrors,
|
|
1445
2733
|
formatHelpDoc: (doc) => {
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
2734
|
+
settle("help", {
|
|
2735
|
+
status: "help",
|
|
2736
|
+
message: plainFormatter.formatHelpDoc(doc),
|
|
1449
2737
|
usage: doc.usage,
|
|
1450
|
-
description: doc.description
|
|
1451
|
-
|
|
1452
|
-
};
|
|
2738
|
+
description: doc.description
|
|
2739
|
+
});
|
|
1453
2740
|
return "";
|
|
1454
2741
|
},
|
|
1455
2742
|
formatVersion: (name, version) => {
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
2743
|
+
settle("version", {
|
|
2744
|
+
status: "version",
|
|
2745
|
+
message: plainFormatter.formatVersion(name, version),
|
|
1459
2746
|
name,
|
|
1460
2747
|
version
|
|
1461
|
-
};
|
|
2748
|
+
});
|
|
1462
2749
|
return "";
|
|
1463
2750
|
}
|
|
1464
2751
|
};
|
|
1465
|
-
const
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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."))]));
|
|
1479
2774
|
const login = Command.make("login", {
|
|
1480
|
-
server: serverFlag,
|
|
1481
2775
|
complete: Flag.boolean("complete").pipe(Flag.withDefault(false), Flag.withDescription("Check a pending authorization once, without waiting.")),
|
|
1482
|
-
restart: Flag.boolean("restart").pipe(Flag.withDefault(false), Flag.withDescription("Start a new authorization code."))
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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);
|
|
1492
2796
|
const publishCommand = Command.make("publish", {
|
|
1493
2797
|
file: Argument.string("file"),
|
|
1494
|
-
|
|
1495
|
-
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,
|
|
1496
2799
|
newDocument: Flag.boolean("new").pipe(Flag.withDefault(false), Flag.withDescription("Explicitly create a separate private document, even when the source matches.")),
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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({
|
|
1502
2807
|
file,
|
|
1503
|
-
server,
|
|
2808
|
+
server: Option.getOrUndefined(server),
|
|
1504
2809
|
newDocument,
|
|
2810
|
+
noMarker,
|
|
1505
2811
|
revise: Option.getOrUndefined(revise),
|
|
1506
2812
|
expectedRevision: Option.getOrUndefined(expectedRevision),
|
|
1507
2813
|
idempotencyKey: Option.getOrUndefined(idempotencyKey),
|
|
@@ -1510,82 +2816,151 @@ const publishCommand = Command.make("publish", {
|
|
|
1510
2816
|
interactive: !json && Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
1511
2817
|
signal
|
|
1512
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
|
+
}
|
|
1513
2899
|
function authCommand(name, operation) {
|
|
1514
|
-
return Effect.
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
};
|
|
1527
|
-
});
|
|
1528
|
-
const ok = [
|
|
1529
|
-
"authorized",
|
|
1530
|
-
"admitted",
|
|
1531
|
-
"published"
|
|
1532
|
-
].includes(result.status);
|
|
1533
|
-
outcome = {
|
|
1534
|
-
ok,
|
|
1535
|
-
command: name,
|
|
1536
|
-
...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)
|
|
1537
2912
|
};
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
"
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
process.removeListener("SIGINT", interrupt);
|
|
1550
|
-
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;
|
|
1551
2924
|
}
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
+
}
|
|
1554
2931
|
});
|
|
1555
2932
|
}
|
|
1556
|
-
const command = Command.make("pageship").pipe(Command.withDescription("Pageship publishes private HTML documents.\n\nEarly development: check
|
|
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([
|
|
1557
2934
|
status,
|
|
1558
2935
|
login,
|
|
1559
2936
|
whoami,
|
|
1560
|
-
publishCommand
|
|
2937
|
+
publishCommand,
|
|
2938
|
+
list,
|
|
2939
|
+
history,
|
|
2940
|
+
download,
|
|
2941
|
+
visibility,
|
|
2942
|
+
restore,
|
|
2943
|
+
skills
|
|
1561
2944
|
]), Command.withGlobalFlags([jsonFlag]));
|
|
1562
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;
|
|
1563
2946
|
const program = Command.runWith(command, {
|
|
1564
2947
|
version,
|
|
1565
2948
|
renderErrors: !json
|
|
1566
2949
|
})(effectiveArgs).pipe(Effect.catch((error) => Effect.sync(() => {
|
|
1567
|
-
|
|
1568
|
-
const
|
|
1569
|
-
const message =
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
}
|
|
1576
|
-
};
|
|
1577
|
-
if (!json && !usage) console.error(`${flagArgs.includes("status") ? "Could not check server compatibility" : "Pageship request failed"}: ${message}`);
|
|
1578
|
-
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);
|
|
1579
2958
|
})), Effect.catchCause((cause) => Effect.sync(() => {
|
|
1580
|
-
process.exitCode = 1;
|
|
1581
2959
|
const message = Cause.pretty(cause);
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
message
|
|
1587
|
-
}
|
|
1588
|
-
};
|
|
2960
|
+
settle(null, {
|
|
2961
|
+
status: "internal_error",
|
|
2962
|
+
message
|
|
2963
|
+
});
|
|
1589
2964
|
if (!json) console.error(message);
|
|
1590
2965
|
})), Effect.provide(NodeServices.layer));
|
|
1591
2966
|
await Effect.runPromise(json ? program.pipe(Effect.provideService(CliOutput.Formatter, jsonFormatter), Effect.provideService(Console.Console, {
|
|
@@ -1593,6 +2968,12 @@ await Effect.runPromise(json ? program.pipe(Effect.provideService(CliOutput.Form
|
|
|
1593
2968
|
log: () => {},
|
|
1594
2969
|
error: () => {}
|
|
1595
2970
|
}), Effect.provide(CliConfig.layer({ builtIns: [GlobalFlag.Help, GlobalFlag.Version] }))) : program);
|
|
1596
|
-
if (json)
|
|
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
|
+
}
|
|
1597
2978
|
//#endregion
|
|
1598
2979
|
export {};
|