uncial-cms 0.0.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 +618 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +15 -0
- package/dist/cli/assert-clean-pages.d.ts +10 -0
- package/dist/cli/assert-clean-pages.js +153 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +3 -0
- package/dist/cli/doctor.d.ts +23 -0
- package/dist/cli/doctor.js +217 -0
- package/dist/cli/run.d.ts +4 -0
- package/dist/cli/run.js +99 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/define-site.d.ts +37 -0
- package/dist/define-site.js +24 -0
- package/dist/deploy-status.d.ts +55 -0
- package/dist/deploy-status.js +118 -0
- package/dist/document.d.ts +6 -0
- package/dist/document.js +23 -0
- package/dist/editor-controller.d.ts +76 -0
- package/dist/editor-controller.js +172 -0
- package/dist/editor-session.d.ts +60 -0
- package/dist/editor-session.js +63 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +14 -0
- package/dist/fit-image.d.ts +31 -0
- package/dist/fit-image.js +88 -0
- package/dist/github/adapter.d.ts +3 -0
- package/dist/github/adapter.js +135 -0
- package/dist/github/index.d.ts +3 -0
- package/dist/github/index.js +3 -0
- package/dist/github/pat.d.ts +7 -0
- package/dist/github/pat.js +35 -0
- package/dist/github/popup.d.ts +9 -0
- package/dist/github/popup.js +75 -0
- package/dist/index-actions.d.ts +74 -0
- package/dist/index-actions.js +147 -0
- package/dist/index-page.d.ts +19 -0
- package/dist/index-page.js +224 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +21 -0
- package/dist/local/adapter.d.ts +2 -0
- package/dist/local/adapter.js +63 -0
- package/dist/local/constants.d.ts +1 -0
- package/dist/local/constants.js +1 -0
- package/dist/local/index.d.ts +4 -0
- package/dist/local/index.js +4 -0
- package/dist/local/session.d.ts +2 -0
- package/dist/local/session.js +12 -0
- package/dist/local/vite.d.ts +8 -0
- package/dist/local/vite.js +243 -0
- package/dist/mount.d.ts +43 -0
- package/dist/mount.js +151 -0
- package/dist/paths/index.d.ts +17 -0
- package/dist/paths/index.js +47 -0
- package/dist/sentinel.d.ts +6 -0
- package/dist/sentinel.js +6 -0
- package/dist/served-url.d.ts +16 -0
- package/dist/served-url.js +19 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +30 -0
- package/dist/svelte/EditorPage.svelte +178 -0
- package/dist/svelte/EditorPage.svelte.d.ts +23 -0
- package/dist/svelte/index.d.ts +5 -0
- package/dist/svelte/index.js +5 -0
- package/dist/svelte/styles.d.ts +4 -0
- package/dist/sveltekit/index.d.ts +68 -0
- package/dist/sveltekit/index.js +98 -0
- package/dist/sveltekit/mapping.d.ts +1 -0
- package/dist/sveltekit/mapping.js +1 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.js +1 -0
- package/dist/upload-context.d.ts +24 -0
- package/dist/upload-context.js +10 -0
- package/dist/vite/index.d.ts +9 -0
- package/dist/vite/index.js +49 -0
- package/package.json +110 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The zero-CMS-JS build gate: Content pages must ship none of this package's
|
|
3
|
+
* JavaScript, Editor variants must ship it, and a local-only site's production
|
|
4
|
+
* build must carry no editor stack at all.
|
|
5
|
+
*
|
|
6
|
+
* Node only, and deliberately dependency-free apart from the sentinel: the
|
|
7
|
+
* published `bin` runs this against a build directory with nothing installed
|
|
8
|
+
* but the package itself.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, relative } from 'node:path';
|
|
12
|
+
import { UNCIAL_CMS_RUNTIME_SENTINEL } from '../sentinel.js';
|
|
13
|
+
/**
|
|
14
|
+
* Names the editor stack cannot be bundled without, and that nothing else in a
|
|
15
|
+
* built site carries. The ProseMirror marker is its class-name prefix rather
|
|
16
|
+
* than the bare word: `ProseMirror-focused` and its siblings are string
|
|
17
|
+
* literals in prosemirror-view that survive minification, while the bare word
|
|
18
|
+
* also appears in a validation message uncial's renderer ships — prose about a
|
|
19
|
+
* document format, not a copy of the library.
|
|
20
|
+
*/
|
|
21
|
+
const EDITOR_STACK_MARKERS = [
|
|
22
|
+
['tiptap', /tiptap/i],
|
|
23
|
+
['ProseMirror-', /ProseMirror-/],
|
|
24
|
+
['uncial-editor', /uncial-editor/]
|
|
25
|
+
];
|
|
26
|
+
function walk(dir) {
|
|
27
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
28
|
+
const path = join(dir, entry.name);
|
|
29
|
+
return entry.isDirectory() ? walk(path) : [path];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Map an href/src (which may include a BASE_PATH prefix) to a file in the build
|
|
34
|
+
* directory: everything the app emits lives under `_app/`, so resolve from that
|
|
35
|
+
* segment.
|
|
36
|
+
*/
|
|
37
|
+
function resolveAppAsset(buildDir, url) {
|
|
38
|
+
const marker = url.indexOf('_app/');
|
|
39
|
+
return marker === -1 ? null : join(buildDir, url.slice(marker));
|
|
40
|
+
}
|
|
41
|
+
/** All JS files reachable from the HTML: script/link tags, then static imports. */
|
|
42
|
+
function scriptClosure(buildDir, html) {
|
|
43
|
+
const queue = [];
|
|
44
|
+
for (const [, url] of html.matchAll(/(?:src|href)="([^"]+\.js)"/g)) {
|
|
45
|
+
const resolved = resolveAppAsset(buildDir, url);
|
|
46
|
+
if (resolved)
|
|
47
|
+
queue.push(resolved);
|
|
48
|
+
}
|
|
49
|
+
// Inline module scripts import chunks by absolute (base-prefixed) URL too.
|
|
50
|
+
for (const [, url] of html.matchAll(/import\(?["']([^"']+\.js)["']/g)) {
|
|
51
|
+
const resolved = resolveAppAsset(buildDir, url);
|
|
52
|
+
if (resolved)
|
|
53
|
+
queue.push(resolved);
|
|
54
|
+
}
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
while (queue.length > 0) {
|
|
57
|
+
const file = queue.pop();
|
|
58
|
+
if (seen.has(file))
|
|
59
|
+
continue;
|
|
60
|
+
seen.add(file);
|
|
61
|
+
let source;
|
|
62
|
+
try {
|
|
63
|
+
source = readFileSync(file, 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue; // external or non-emitted reference
|
|
67
|
+
}
|
|
68
|
+
// Static imports only: the kit router *dynamically* imports every route
|
|
69
|
+
// module lazily, and those never load on a content page.
|
|
70
|
+
for (const [, spec] of source.matchAll(/(?:from|import)\s*["']([^"']+\.js)["']/g)) {
|
|
71
|
+
if (spec.startsWith('.'))
|
|
72
|
+
queue.push(join(dirname(file), spec));
|
|
73
|
+
else {
|
|
74
|
+
const resolved = resolveAppAsset(buildDir, spec);
|
|
75
|
+
if (resolved)
|
|
76
|
+
queue.push(resolved);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return seen;
|
|
81
|
+
}
|
|
82
|
+
function pageContainsSentinel(buildDir, htmlPath) {
|
|
83
|
+
const html = readFileSync(htmlPath, 'utf-8');
|
|
84
|
+
if (html.includes(UNCIAL_CMS_RUNTIME_SENTINEL))
|
|
85
|
+
return true;
|
|
86
|
+
for (const file of scriptClosure(buildDir, html)) {
|
|
87
|
+
if (readFileSync(file, 'utf-8').includes(UNCIAL_CMS_RUNTIME_SENTINEL))
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/** Site-relative page path of an `index.html`, as the failure messages name it. */
|
|
93
|
+
function pageOf(buildDir, htmlPath) {
|
|
94
|
+
return `/${relative(buildDir, dirname(htmlPath))}/`.replace(/^\/\.\/$/, '/');
|
|
95
|
+
}
|
|
96
|
+
export function assertCleanPages(buildDir, options, io) {
|
|
97
|
+
let files;
|
|
98
|
+
try {
|
|
99
|
+
files = walk(buildDir);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
io.err(`No pages found under "${buildDir}" — build the site first.`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
const htmlFiles = files.filter((path) => path.endsWith('index.html'));
|
|
106
|
+
if (htmlFiles.length === 0) {
|
|
107
|
+
io.err(`No pages found under "${buildDir}" — build the site first.`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const failures = [];
|
|
111
|
+
for (const htmlPath of htmlFiles) {
|
|
112
|
+
const page = pageOf(buildDir, htmlPath);
|
|
113
|
+
const isEditPage = page.endsWith('/edit/');
|
|
114
|
+
const isIndexPage = page === '/uncial/';
|
|
115
|
+
const hasSentinel = pageContainsSentinel(buildDir, htmlPath);
|
|
116
|
+
if (isEditPage && !options.localOnly && !hasSentinel) {
|
|
117
|
+
failures.push(`${page} is an editor variant but does not reference the CMS runtime.`);
|
|
118
|
+
}
|
|
119
|
+
else if (!isEditPage && !isIndexPage && hasSentinel) {
|
|
120
|
+
failures.push(`${page} is a content page but ships uncial-cms JavaScript.`);
|
|
121
|
+
}
|
|
122
|
+
if (options.localOnly && isEditPage) {
|
|
123
|
+
failures.push(`${page} is an editor variant, but a local-only build must ship none.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (options.localOnly) {
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
let source;
|
|
129
|
+
try {
|
|
130
|
+
source = readFileSync(file, 'utf-8');
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const name = relative(buildDir, file);
|
|
136
|
+
if (source.includes(UNCIAL_CMS_RUNTIME_SENTINEL)) {
|
|
137
|
+
failures.push(`${name} carries the CMS runtime sentinel (${UNCIAL_CMS_RUNTIME_SENTINEL}).`);
|
|
138
|
+
}
|
|
139
|
+
for (const [marker, pattern] of EDITOR_STACK_MARKERS) {
|
|
140
|
+
if (pattern.test(source))
|
|
141
|
+
failures.push(`${name} carries the editor stack (${marker}).`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (failures.length > 0) {
|
|
146
|
+
io.err('assert:clean-pages FAILED');
|
|
147
|
+
for (const failure of failures)
|
|
148
|
+
io.err(` - ${failure}`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
io.out(`assert:clean-pages OK — ${htmlFiles.length} pages checked, content pages are sentinel-free.`);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
package/dist/cli/bin.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CliOutput } from './assert-clean-pages.js';
|
|
2
|
+
export interface GhResult {
|
|
3
|
+
stdout: string;
|
|
4
|
+
/** Process exit status; a non-zero status is a refusal, never a throw. */
|
|
5
|
+
status: number;
|
|
6
|
+
}
|
|
7
|
+
export type GhInvoker = (args: string[]) => Promise<GhResult>;
|
|
8
|
+
export interface DoctorOptions {
|
|
9
|
+
/** The site's origin, as the Allowlist and the auth worker spell it. */
|
|
10
|
+
origin: string;
|
|
11
|
+
/** `owner/name`; defaults to the repository `gh` sees in the working tree. */
|
|
12
|
+
repo?: string;
|
|
13
|
+
appSlug?: string;
|
|
14
|
+
/** Ref the Allowlist is read from; defaults to the repository's default branch. */
|
|
15
|
+
branch?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Runs the real `gh`. A missing binary is a non-zero status, not an exception. */
|
|
18
|
+
export declare function ghCli(): GhInvoker;
|
|
19
|
+
/**
|
|
20
|
+
* Exit 0 when every check passes, 1 when one fails, and 2 when the command
|
|
21
|
+
* could not run at all — no authenticated `gh`, or no repository to check.
|
|
22
|
+
*/
|
|
23
|
+
export declare function doctor(options: DoctorOptions, io: CliOutput, gh?: GhInvoker): Promise<number>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provisioning checks a site owner would otherwise learn about from a
|
|
3
|
+
* failed sign-in: the GitHub App installed on the repository, the Allowlist
|
|
4
|
+
* committed to the default branch naming the site's origin, and Pages serving
|
|
5
|
+
* that origin. Each failure names the auth worker refusal it would produce.
|
|
6
|
+
*
|
|
7
|
+
* Every fact comes from the authenticated `gh` CLI through one injectable
|
|
8
|
+
* invoker, so the specs never touch the network and the command never needs a
|
|
9
|
+
* token of its own.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { DEFAULT_APP_SLUG } from '../define-site.js';
|
|
13
|
+
/** Runs the real `gh`. A missing binary is a non-zero status, not an exception. */
|
|
14
|
+
export function ghCli() {
|
|
15
|
+
return (args) => new Promise((resolve) => {
|
|
16
|
+
execFile('gh', args, { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
|
|
17
|
+
const code = error?.code;
|
|
18
|
+
resolve({ stdout, status: error === null ? 0 : typeof code === 'number' ? code : 1 });
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const GH_INSTRUCTION = '`gh` is missing or not authenticated. Install it from https://cli.github.com and run `gh auth login`, then run this command again.';
|
|
23
|
+
class Report {
|
|
24
|
+
io;
|
|
25
|
+
failed = false;
|
|
26
|
+
constructor(io) {
|
|
27
|
+
this.io = io;
|
|
28
|
+
}
|
|
29
|
+
pass(line) {
|
|
30
|
+
this.io.out(`✓ ${line}`);
|
|
31
|
+
}
|
|
32
|
+
/** `code` is the auth worker refusal this failure would have produced. */
|
|
33
|
+
fail(line, fix, code) {
|
|
34
|
+
this.failed = true;
|
|
35
|
+
this.io.err(`✗ ${line}${code ? ` (${code})` : ''}`);
|
|
36
|
+
this.io.err(` Fix: ${fix}`);
|
|
37
|
+
}
|
|
38
|
+
warn(line) {
|
|
39
|
+
this.io.out(`! ${line}`);
|
|
40
|
+
}
|
|
41
|
+
get exitCode() {
|
|
42
|
+
return this.failed ? 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Non-empty trimmed lines of a `--jq` result. */
|
|
46
|
+
function lines(stdout) {
|
|
47
|
+
return stdout
|
|
48
|
+
.split('\n')
|
|
49
|
+
.map((line) => line.trim())
|
|
50
|
+
.filter((line) => line.length > 0);
|
|
51
|
+
}
|
|
52
|
+
function parseJson(stdout) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(stdout);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function checkAppInstalled(gh, report, repo, appSlug) {
|
|
61
|
+
// `GET /repos/{owner}/{repo}/installation` needs App auth, so ask the
|
|
62
|
+
// installations the authenticated user can see which repositories they cover.
|
|
63
|
+
const installations = await gh([
|
|
64
|
+
'api',
|
|
65
|
+
'--paginate',
|
|
66
|
+
'/user/installations',
|
|
67
|
+
'--jq',
|
|
68
|
+
`.installations[] | select(.app_slug == "${appSlug}") | .id`
|
|
69
|
+
]);
|
|
70
|
+
const installUrl = `https://github.com/apps/${appSlug}/installations/new`;
|
|
71
|
+
// A `gh auth login` token is not authorized to a GitHub App, so this route
|
|
72
|
+
// answers 403 for most users. That is unknown, not absent: reporting it as a
|
|
73
|
+
// failure would make every real run red on a check nothing here can settle.
|
|
74
|
+
if (installations.status !== 0) {
|
|
75
|
+
report.warn(`could not list "${appSlug}" App installations — \`gh\`'s token is not authorized to a GitHub App. Confirm the install by eye at ${installUrl}.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const ids = lines(installations.stdout);
|
|
79
|
+
if (ids.length === 0) {
|
|
80
|
+
report.fail(`the "${appSlug}" GitHub App is not installed on any repository you can see`, `install it at ${installUrl}`, 'app_not_installed');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let refused = false;
|
|
84
|
+
for (const id of ids) {
|
|
85
|
+
const repositories = await gh([
|
|
86
|
+
'api',
|
|
87
|
+
'--paginate',
|
|
88
|
+
`/user/installations/${id}/repositories`,
|
|
89
|
+
'--jq',
|
|
90
|
+
'.repositories[].full_name'
|
|
91
|
+
]);
|
|
92
|
+
if (repositories.status !== 0) {
|
|
93
|
+
refused = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (lines(repositories.stdout).some((name) => name.toLowerCase() === repo.toLowerCase())) {
|
|
97
|
+
report.pass(`the "${appSlug}" GitHub App is installed on ${repo}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (refused) {
|
|
102
|
+
report.warn(`could not list the repositories the "${appSlug}" App is installed on. Confirm ${repo} is among them at ${installUrl}.`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
report.fail(`the "${appSlug}" GitHub App is installed, but not on ${repo}`, `grant it access to ${repo} at ${installUrl}`, 'app_not_installed');
|
|
106
|
+
}
|
|
107
|
+
async function checkPushPermission(gh, report, repo) {
|
|
108
|
+
const user = await gh(['api', 'user', '--jq', '.login']);
|
|
109
|
+
const login = lines(user.stdout)[0];
|
|
110
|
+
if (user.status !== 0 || login === undefined) {
|
|
111
|
+
report.fail('could not read the authenticated user from `gh`', 'run `gh auth status` and re-authenticate', 'no_push_permission');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const permission = await gh([
|
|
115
|
+
'api',
|
|
116
|
+
`repos/${repo}/collaborators/${login}/permission`,
|
|
117
|
+
'--jq',
|
|
118
|
+
'.user.permissions.push'
|
|
119
|
+
]);
|
|
120
|
+
if (permission.status === 0 && lines(permission.stdout)[0] === 'true') {
|
|
121
|
+
report.pass(`${login} has push permission on ${repo}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
report.fail(`${login} does not have push permission on ${repo}`, `ask an admin of ${repo} for write access — the editor commits as you`, 'no_push_permission');
|
|
125
|
+
}
|
|
126
|
+
async function checkAllowlist(gh, report, repo, origin, branch) {
|
|
127
|
+
const ref = branch === undefined ? '' : `?ref=${branch}`;
|
|
128
|
+
const where = branch === undefined ? 'the default branch' : `${branch}`;
|
|
129
|
+
const file = await gh([
|
|
130
|
+
'api',
|
|
131
|
+
'-H',
|
|
132
|
+
'Accept: application/vnd.github.raw',
|
|
133
|
+
`repos/${repo}/contents/.uncial/cms.json${ref}`
|
|
134
|
+
]);
|
|
135
|
+
if (file.status !== 0) {
|
|
136
|
+
report.fail(`.uncial/cms.json is not on ${where} of ${repo}`, `commit { "allowedOrigins": ["${origin}"] } to .uncial/cms.json on ${where}`, 'missing_allowlist');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const parsed = parseJson(file.stdout);
|
|
140
|
+
const allowed = typeof parsed === 'object' && parsed !== null
|
|
141
|
+
? parsed.allowedOrigins
|
|
142
|
+
: undefined;
|
|
143
|
+
if (!Array.isArray(allowed) || allowed.some((entry) => typeof entry !== 'string')) {
|
|
144
|
+
report.fail(`.uncial/cms.json on ${where} of ${repo} has no "allowedOrigins" array of strings`, `make it { "allowedOrigins": ["${origin}"] }`, 'missing_allowlist');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
report.pass(`.uncial/cms.json on ${where} of ${repo} lists ${allowed.length} origin(s)`);
|
|
148
|
+
if (allowed.includes(origin)) {
|
|
149
|
+
report.pass(`${origin} is allowlisted`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
report.fail(`${origin} is not in the allowlist (${allowed.join(', ') || 'empty'})`, `add "${origin}" to allowedOrigins in .uncial/cms.json — the worker matches the origin string exactly`, 'origin_not_allowed');
|
|
153
|
+
}
|
|
154
|
+
async function checkPages(gh, report, repo, host) {
|
|
155
|
+
const settingsUrl = `https://github.com/${repo}/settings/pages`;
|
|
156
|
+
const pages = await gh(['api', `repos/${repo}/pages`]);
|
|
157
|
+
if (pages.status !== 0) {
|
|
158
|
+
report.fail(`GitHub Pages is not enabled for ${repo}`, `enable it at ${settingsUrl}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
report.pass(`GitHub Pages is enabled for ${repo}`);
|
|
162
|
+
if (host.endsWith('.github.io'))
|
|
163
|
+
return;
|
|
164
|
+
const settings = parseJson(pages.stdout);
|
|
165
|
+
const cname = typeof settings?.cname === 'string' ? settings.cname : null;
|
|
166
|
+
if (cname !== host) {
|
|
167
|
+
report.fail(`the Pages custom domain is ${cname ?? 'unset'}, not ${host}`, `set the custom domain to ${host} at ${settingsUrl}`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
report.pass(`the Pages custom domain is ${host}`);
|
|
171
|
+
}
|
|
172
|
+
if (settings?.https_enforced === true) {
|
|
173
|
+
report.pass('Pages enforces HTTPS');
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
report.fail(`Pages does not enforce HTTPS for ${host}`, `tick "Enforce HTTPS" at ${settingsUrl} once the certificate is issued`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Exit 0 when every check passes, 1 when one fails, and 2 when the command
|
|
181
|
+
* could not run at all — no authenticated `gh`, or no repository to check.
|
|
182
|
+
*/
|
|
183
|
+
export async function doctor(options, io, gh = ghCli()) {
|
|
184
|
+
let origin;
|
|
185
|
+
try {
|
|
186
|
+
origin = new URL(options.origin);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
io.err(`"${options.origin}" is not a URL — pass an origin like https://example.com.`);
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
const auth = await gh(['auth', 'status']);
|
|
193
|
+
if (auth.status !== 0) {
|
|
194
|
+
io.err(GH_INSTRUCTION);
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
let repo = options.repo;
|
|
198
|
+
if (repo === undefined) {
|
|
199
|
+
const view = await gh(['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']);
|
|
200
|
+
repo = view.status === 0 ? lines(view.stdout)[0] : undefined;
|
|
201
|
+
if (repo === undefined) {
|
|
202
|
+
io.err('Could not determine the repository — run this in a checkout or pass --repo owner/name.');
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const appSlug = options.appSlug ?? DEFAULT_APP_SLUG;
|
|
207
|
+
const report = new Report(io);
|
|
208
|
+
report.pass('`gh` is installed and authenticated');
|
|
209
|
+
await checkAppInstalled(gh, report, repo, appSlug);
|
|
210
|
+
await checkPushPermission(gh, report, repo);
|
|
211
|
+
await checkAllowlist(gh, report, repo, origin.origin, options.branch);
|
|
212
|
+
await checkPages(gh, report, repo, origin.host);
|
|
213
|
+
if (origin.host.endsWith('.github.io')) {
|
|
214
|
+
report.warn(`${origin.host} is a shared origin: allowlisting it authorises every project page served from it. A custom domain restores per-site granularity.`);
|
|
215
|
+
}
|
|
216
|
+
return report.exitCode;
|
|
217
|
+
}
|
package/dist/cli/run.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Argument parsing and dispatch for the `uncial-cms` command. */
|
|
2
|
+
import { assertCleanPages } from './assert-clean-pages.js';
|
|
3
|
+
import { DEFAULT_APP_SLUG } from '../define-site.js';
|
|
4
|
+
import { doctor } from './doctor.js';
|
|
5
|
+
const USAGE = `uncial-cms — build gates and provisioning checks for a site that edits itself
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
uncial-cms assert-clean-pages [buildDir] [--local-only]
|
|
9
|
+
|
|
10
|
+
Assert that Content pages ship no uncial-cms JavaScript and that every
|
|
11
|
+
Editor variant does. buildDir defaults to "build".
|
|
12
|
+
|
|
13
|
+
--local-only Assert a local-only site's production build instead: no
|
|
14
|
+
Editor variant exists, and no file in the build carries the
|
|
15
|
+
CMS runtime sentinel or the editor stack.
|
|
16
|
+
|
|
17
|
+
uncial-cms doctor --origin <https://host> [--repo owner/name]
|
|
18
|
+
[--app-slug <slug>] [--branch <ref>]
|
|
19
|
+
|
|
20
|
+
Check, through the authenticated \`gh\` CLI, that the GitHub App is
|
|
21
|
+
installed on the repository, that .uncial/cms.json is committed and lists
|
|
22
|
+
the origin, and that Pages serves it. Each failure names the auth worker
|
|
23
|
+
refusal it would have produced.
|
|
24
|
+
|
|
25
|
+
--origin The site's origin, spelled as the allowlist spells it.
|
|
26
|
+
--repo Defaults to the repository of the current checkout.
|
|
27
|
+
--app-slug Defaults to "${DEFAULT_APP_SLUG}".
|
|
28
|
+
--branch Ref the allowlist is read from; defaults to the default branch.
|
|
29
|
+
|
|
30
|
+
uncial-cms --help Print this message.`;
|
|
31
|
+
export const consoleOutput = {
|
|
32
|
+
out: (line) => console.log(line),
|
|
33
|
+
err: (line) => console.error(line)
|
|
34
|
+
};
|
|
35
|
+
/** Flags taking a value, in either `--flag value` or `--flag=value` form. */
|
|
36
|
+
function parseFlags(rest, names) {
|
|
37
|
+
const values = {};
|
|
38
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
39
|
+
const arg = rest[i];
|
|
40
|
+
const [name, inline] = arg.startsWith('--') && arg.includes('=')
|
|
41
|
+
? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]
|
|
42
|
+
: [arg, undefined];
|
|
43
|
+
if (!names.includes(name))
|
|
44
|
+
return { values, error: `Unknown option "${arg}".` };
|
|
45
|
+
const value = inline ?? rest[i + 1];
|
|
46
|
+
if (value === undefined || value.startsWith('-')) {
|
|
47
|
+
return { values, error: `Option "${name}" needs a value.` };
|
|
48
|
+
}
|
|
49
|
+
if (inline === undefined)
|
|
50
|
+
i += 1;
|
|
51
|
+
values[name] = value;
|
|
52
|
+
}
|
|
53
|
+
return { values, error: null };
|
|
54
|
+
}
|
|
55
|
+
export async function run(argv, io = consoleOutput, gh) {
|
|
56
|
+
const [command, ...rest] = argv;
|
|
57
|
+
if (command === undefined || command === '--help' || command === '-h') {
|
|
58
|
+
io.out(USAGE);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
if (command !== 'assert-clean-pages' && command !== 'doctor') {
|
|
62
|
+
io.err(`Unknown command "${command}".`);
|
|
63
|
+
io.err(USAGE);
|
|
64
|
+
return 2;
|
|
65
|
+
}
|
|
66
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
67
|
+
io.out(USAGE);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
if (command === 'doctor') {
|
|
71
|
+
const { values, error } = parseFlags(rest, ['--origin', '--repo', '--app-slug', '--branch']);
|
|
72
|
+
if (error !== null) {
|
|
73
|
+
io.err(error);
|
|
74
|
+
io.err(USAGE);
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
const origin = values['--origin'];
|
|
78
|
+
if (origin === undefined) {
|
|
79
|
+
io.err('doctor needs --origin <https://host>.');
|
|
80
|
+
io.err(USAGE);
|
|
81
|
+
return 2;
|
|
82
|
+
}
|
|
83
|
+
return doctor({
|
|
84
|
+
origin,
|
|
85
|
+
repo: values['--repo'],
|
|
86
|
+
appSlug: values['--app-slug'],
|
|
87
|
+
branch: values['--branch']
|
|
88
|
+
}, io, gh);
|
|
89
|
+
}
|
|
90
|
+
const localOnly = rest.includes('--local-only');
|
|
91
|
+
const positional = rest.filter((arg) => !arg.startsWith('-'));
|
|
92
|
+
const unknownFlag = rest.find((arg) => arg.startsWith('-') && arg !== '--local-only');
|
|
93
|
+
if (unknownFlag !== undefined) {
|
|
94
|
+
io.err(`Unknown option "${unknownFlag}".`);
|
|
95
|
+
io.err(USAGE);
|
|
96
|
+
return 2;
|
|
97
|
+
}
|
|
98
|
+
return assertCleanPages(positional[0] ?? 'build', { localOnly }, io);
|
|
99
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub Contents API rejects file payloads larger than ~1 MB. Reads,
|
|
3
|
+
* document writes, and asset uploads all share this ceiling; there is no
|
|
4
|
+
* git-blobs-API fallback (see the uncial-cms spec's media non-goal).
|
|
5
|
+
*/
|
|
6
|
+
export declare const MAX_CONTENT_BYTES: number;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub Contents API rejects file payloads larger than ~1 MB. Reads,
|
|
3
|
+
* document writes, and asset uploads all share this ceiling; there is no
|
|
4
|
+
* git-blobs-API fallback (see the uncial-cms spec's media non-goal).
|
|
5
|
+
*/
|
|
6
|
+
export const MAX_CONTENT_BYTES = 1024 * 1024;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One declaration of a site's CMS configuration, resolved for the current
|
|
3
|
+
* build: the local forge while developing, the declared GitHub forge in a
|
|
4
|
+
* production build, and a local-only site when no GitHub half is declared.
|
|
5
|
+
*/
|
|
6
|
+
import type { UncialCmsSiteConfig } from './types.js';
|
|
7
|
+
/** The auth worker this project operates for sites using the canonical GitHub App. */
|
|
8
|
+
export declare const DEFAULT_AUTH_WORKER_URL = "https://uncial-cms-auth.dflood.workers.dev";
|
|
9
|
+
/** The canonical GitHub App a site installs on its repository. */
|
|
10
|
+
export declare const DEFAULT_APP_SLUG = "uncial-cms";
|
|
11
|
+
export interface SiteOptions {
|
|
12
|
+
/** Repo-root-relative content directory, as the forge APIs address it. */
|
|
13
|
+
contentDir: string;
|
|
14
|
+
/** FS path of that directory at build time; defaults to `contentDir`. */
|
|
15
|
+
localContentDir?: string;
|
|
16
|
+
/** Repo-root-relative directory uploaded media commits into. */
|
|
17
|
+
mediaDir?: string;
|
|
18
|
+
/** Omit for a local-only site: one with no forge to commit to. */
|
|
19
|
+
github?: {
|
|
20
|
+
repo: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
authWorkerUrl?: string;
|
|
23
|
+
appSlug?: string;
|
|
24
|
+
};
|
|
25
|
+
/** Honoured only when the resolved forge is local; a forge commit is never autosaved. */
|
|
26
|
+
autosaveMs?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface Site {
|
|
29
|
+
config: UncialCmsSiteConfig;
|
|
30
|
+
/** No GitHub half was declared, so this site is only editable in development. */
|
|
31
|
+
localOnly: boolean;
|
|
32
|
+
autosaveMs: number | undefined;
|
|
33
|
+
localContentDir: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function defineSite(options: SiteOptions, env?: {
|
|
36
|
+
dev: boolean;
|
|
37
|
+
}): Site;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The auth worker this project operates for sites using the canonical GitHub App. */
|
|
2
|
+
export const DEFAULT_AUTH_WORKER_URL = 'https://uncial-cms-auth.dflood.workers.dev';
|
|
3
|
+
/** The canonical GitHub App a site installs on its repository. */
|
|
4
|
+
export const DEFAULT_APP_SLUG = 'uncial-cms';
|
|
5
|
+
export function defineSite(options, env = { dev: import.meta.env.DEV }) {
|
|
6
|
+
const localOnly = options.github === undefined;
|
|
7
|
+
const config = env.dev || options.github === undefined
|
|
8
|
+
? { forge: 'local', contentDir: options.contentDir, mediaDir: options.mediaDir }
|
|
9
|
+
: {
|
|
10
|
+
forge: 'github',
|
|
11
|
+
repo: options.github.repo,
|
|
12
|
+
branch: options.github.branch,
|
|
13
|
+
contentDir: options.contentDir,
|
|
14
|
+
authWorkerUrl: options.github.authWorkerUrl ?? DEFAULT_AUTH_WORKER_URL,
|
|
15
|
+
appSlug: options.github.appSlug ?? DEFAULT_APP_SLUG,
|
|
16
|
+
mediaDir: options.mediaDir
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
config,
|
|
20
|
+
localOnly,
|
|
21
|
+
autosaveMs: config.forge === 'local' ? options.autosaveMs : undefined,
|
|
22
|
+
localContentDir: options.localContentDir ?? options.contentDir
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-save deploy-status lifecycle (ticket 05, SPEC §6.6 / D5).
|
|
3
|
+
*
|
|
4
|
+
* After a save commits, the runtime polls `adapter.commitStatus(commitSha)` and
|
|
5
|
+
* surfaces the deploy lifecycle: `committed → building… → live` (or
|
|
6
|
+
* `build failed`, or a calm `status unknown` when the repo reports no checks).
|
|
7
|
+
* The timing constants live here; tests shrink them via injection.
|
|
8
|
+
*/
|
|
9
|
+
export type CommitStatus = 'pending' | 'success' | 'failure' | 'unknown';
|
|
10
|
+
export type DeployPhase = 'committed' | 'building' | 'live' | 'failed' | 'unknown' | 'timeout';
|
|
11
|
+
export interface DeployStatusTimings {
|
|
12
|
+
/** Delay before the first commit-status poll. */
|
|
13
|
+
firstDelayMs: number;
|
|
14
|
+
/** Delay between subsequent polls. */
|
|
15
|
+
intervalMs: number;
|
|
16
|
+
/** Hard stop; after this the phase becomes `timeout` and polling ends. */
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const DEFAULT_DEPLOY_STATUS_TIMINGS: DeployStatusTimings;
|
|
20
|
+
/** Map a forge commit status to a deploy phase and whether polling should stop. */
|
|
21
|
+
export declare function deployPhaseForStatus(status: CommitStatus): {
|
|
22
|
+
phase: DeployPhase;
|
|
23
|
+
done: boolean;
|
|
24
|
+
};
|
|
25
|
+
export interface DeployStatusView {
|
|
26
|
+
text: string;
|
|
27
|
+
/** Commit permalink on the forge, offered as a follow-up link. */
|
|
28
|
+
commitUrl: string;
|
|
29
|
+
/** Non-`failed` phases must render calmly, never as errors (D5). */
|
|
30
|
+
tone: 'progress' | 'success' | 'error';
|
|
31
|
+
}
|
|
32
|
+
export declare function githubCommitUrl(repo: string, commitSha: string): string;
|
|
33
|
+
/** Human-facing copy for a phase; the branch is always named (ticket contract). */
|
|
34
|
+
export declare function describeDeployPhase(phase: DeployPhase, ctx: {
|
|
35
|
+
branch: string;
|
|
36
|
+
commitSha: string;
|
|
37
|
+
commitUrl: string;
|
|
38
|
+
}): DeployStatusView;
|
|
39
|
+
export type Schedule = (fn: () => void, ms: number) => () => void;
|
|
40
|
+
export declare const defaultSchedule: Schedule;
|
|
41
|
+
export interface DeployPollHandle {
|
|
42
|
+
cancel(): void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Poll `check` until a terminal status or the timeout. Emits `committed`
|
|
46
|
+
* synchronously, then a phase per poll. Stops on any terminal phase, on
|
|
47
|
+
* timeout, or on `cancel()`. Never throws — a failed check retries until the
|
|
48
|
+
* deadline.
|
|
49
|
+
*/
|
|
50
|
+
export declare function startDeployPolling(opts: {
|
|
51
|
+
check: () => Promise<CommitStatus>;
|
|
52
|
+
onPhase: (phase: DeployPhase) => void;
|
|
53
|
+
timings?: DeployStatusTimings;
|
|
54
|
+
schedule?: Schedule;
|
|
55
|
+
}): DeployPollHandle;
|