@m-kopa/launchpad-cli 0.23.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/CHANGELOG.md +854 -0
- package/README.md +109 -0
- package/dist/auth/browser.d.ts +18 -0
- package/dist/auth/browser.d.ts.map +1 -0
- package/dist/auth/callback-server.d.ts +24 -0
- package/dist/auth/callback-server.d.ts.map +1 -0
- package/dist/auth/discovery.d.ts +25 -0
- package/dist/auth/discovery.d.ts.map +1 -0
- package/dist/auth/flow.d.ts +39 -0
- package/dist/auth/flow.d.ts.map +1 -0
- package/dist/auth/jwt.d.ts +27 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/pkce.d.ts +26 -0
- package/dist/auth/pkce.d.ts.map +1 -0
- package/dist/auth/registration.d.ts +8 -0
- package/dist/auth/registration.d.ts.map +1 -0
- package/dist/auth/session.d.ts +54 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/token.d.ts +37 -0
- package/dist/auth/token.d.ts.map +1 -0
- package/dist/bundle/cron-bundle.d.ts +77 -0
- package/dist/bundle/cron-bundle.d.ts.map +1 -0
- package/dist/bundle/cwd-walker.d.ts +43 -0
- package/dist/bundle/cwd-walker.d.ts.map +1 -0
- package/dist/bundle/orchestrate.d.ts +51 -0
- package/dist/bundle/orchestrate.d.ts.map +1 -0
- package/dist/bundle/upload.d.ts +66 -0
- package/dist/bundle/upload.d.ts.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +9757 -0
- package/dist/clone/git-init.d.ts +18 -0
- package/dist/clone/git-init.d.ts.map +1 -0
- package/dist/clone/tar-extract.d.ts +59 -0
- package/dist/clone/tar-extract.d.ts.map +1 -0
- package/dist/commands/apps.d.ts +14 -0
- package/dist/commands/apps.d.ts.map +1 -0
- package/dist/commands/channel-auth.d.ts +31 -0
- package/dist/commands/channel-auth.d.ts.map +1 -0
- package/dist/commands/clone.d.ts +3 -0
- package/dist/commands/clone.d.ts.map +1 -0
- package/dist/commands/create.d.ts +27 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/deploy-flags.d.ts +75 -0
- package/dist/commands/deploy-flags.d.ts.map +1 -0
- package/dist/commands/deploy-modes.d.ts +59 -0
- package/dist/commands/deploy-modes.d.ts.map +1 -0
- package/dist/commands/deploy.d.ts +29 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/destroy.d.ts +14 -0
- package/dist/commands/destroy.d.ts.map +1 -0
- package/dist/commands/envvars.d.ts +28 -0
- package/dist/commands/envvars.d.ts.map +1 -0
- package/dist/commands/generate.d.ts +3 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/groups-whoami.d.ts +3 -0
- package/dist/commands/groups-whoami.d.ts.map +1 -0
- package/dist/commands/groups.d.ts +3 -0
- package/dist/commands/groups.d.ts.map +1 -0
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logs.d.ts +16 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/merge.d.ts +29 -0
- package/dist/commands/merge.d.ts.map +1 -0
- package/dist/commands/plan.d.ts +3 -0
- package/dist/commands/plan.d.ts.map +1 -0
- package/dist/commands/pull.d.ts +12 -0
- package/dist/commands/pull.d.ts.map +1 -0
- package/dist/commands/review.d.ts +22 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/commands/rollback.d.ts +3 -0
- package/dist/commands/rollback.d.ts.map +1 -0
- package/dist/commands/secrets-template.d.ts +3 -0
- package/dist/commands/secrets-template.d.ts.map +1 -0
- package/dist/commands/secrets.d.ts +3 -0
- package/dist/commands/secrets.d.ts.map +1 -0
- package/dist/commands/skills.d.ts +13 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/status.d.ts +54 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/update.d.ts +114 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/validate.d.ts +3 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/deploy/apply.d.ts +29 -0
- package/dist/deploy/apply.d.ts.map +1 -0
- package/dist/deploy/dry-run.d.ts +13 -0
- package/dist/deploy/dry-run.d.ts.map +1 -0
- package/dist/deploy/git-files.d.ts +33 -0
- package/dist/deploy/git-files.d.ts.map +1 -0
- package/dist/deploy/group-pin.d.ts +66 -0
- package/dist/deploy/group-pin.d.ts.map +1 -0
- package/dist/deploy/manifest-state.d.ts +20 -0
- package/dist/deploy/manifest-state.d.ts.map +1 -0
- package/dist/deploy/manifest-status.d.ts +11 -0
- package/dist/deploy/manifest-status.d.ts.map +1 -0
- package/dist/deploy/resolve.d.ts +53 -0
- package/dist/deploy/resolve.d.ts.map +1 -0
- package/dist/deploy/rollback.d.ts +23 -0
- package/dist/deploy/rollback.d.ts.map +1 -0
- package/dist/deploy/runner.d.ts +29 -0
- package/dist/deploy/runner.d.ts.map +1 -0
- package/dist/deploy/stage-exit-codes.d.ts +41 -0
- package/dist/deploy/stage-exit-codes.d.ts.map +1 -0
- package/dist/deploy/status-polling.d.ts +37 -0
- package/dist/deploy/status-polling.d.ts.map +1 -0
- package/dist/deploy/tar-pack.d.ts +22 -0
- package/dist/deploy/tar-pack.d.ts.map +1 -0
- package/dist/detect/index.d.ts +53 -0
- package/dist/detect/index.d.ts.map +1 -0
- package/dist/dispatcher.d.ts +30 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/groups/client.d.ts +62 -0
- package/dist/groups/client.d.ts.map +1 -0
- package/dist/http/api-client.d.ts +33 -0
- package/dist/http/api-client.d.ts.map +1 -0
- package/dist/http/errors.d.ts +31 -0
- package/dist/http/errors.d.ts.map +1 -0
- package/dist/manifest/load.d.ts +38 -0
- package/dist/manifest/load.d.ts.map +1 -0
- package/dist/manifest/schema.d.ts +3 -0
- package/dist/manifest/schema.d.ts.map +1 -0
- package/dist/postinstall.d.ts +3 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +37 -0
- package/dist/secrets/env-parse.d.ts +19 -0
- package/dist/secrets/env-parse.d.ts.map +1 -0
- package/dist/secrets/push.d.ts +13 -0
- package/dist/secrets/push.d.ts.map +1 -0
- package/dist/secrets/set.d.ts +19 -0
- package/dist/secrets/set.d.ts.map +1 -0
- package/dist/secrets/status.d.ts +19 -0
- package/dist/secrets/status.d.ts.map +1 -0
- package/dist/types/api.d.ts +112 -0
- package/dist/types/api.d.ts.map +1 -0
- package/dist/update-notifier.d.ts +69 -0
- package/dist/update-notifier.d.ts.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +62 -0
- package/skills/README.md +100 -0
- package/skills/_partials/shell-contract.md +42 -0
- package/skills/launchpad-content-pr/SKILL.md +255 -0
- package/skills/launchpad-deploy/SKILL.md +415 -0
- package/skills/launchpad-deploy-status/SKILL.md +231 -0
- package/skills/launchpad-destroy/SKILL.md +317 -0
- package/skills/launchpad-onboard/SKILL.md +179 -0
- package/skills/launchpad-status/SKILL.md +263 -0
- package/skills/marquee-share/README.md +155 -0
- package/skills/marquee-share/SKILL.md +94 -0
- package/skills/marquee-share/SYNC.md +27 -0
- package/skills/marquee-share/dist/cli.js +896 -0
- package/skills/marquee-share/eslint.config.mjs +71 -0
- package/skills/marquee-share/install.sh +103 -0
- package/skills/marquee-share/package-lock.json +3946 -0
- package/skills/marquee-share/package.json +30 -0
- package/skills/marquee-share/src/auth/PROVENANCE.md +103 -0
- package/skills/marquee-share/src/auth/browser.ts +75 -0
- package/skills/marquee-share/src/auth/callback-server.ts +171 -0
- package/skills/marquee-share/src/auth/discovery.ts +171 -0
- package/skills/marquee-share/src/auth/flow.ts +262 -0
- package/skills/marquee-share/src/auth/index.ts +171 -0
- package/skills/marquee-share/src/auth/jwt.ts +77 -0
- package/skills/marquee-share/src/auth/pkce.ts +79 -0
- package/skills/marquee-share/src/auth/registration.ts +87 -0
- package/skills/marquee-share/src/auth/session.ts +205 -0
- package/skills/marquee-share/src/auth/token.ts +162 -0
- package/skills/marquee-share/src/cli.ts +246 -0
- package/skills/marquee-share/src/config.ts +101 -0
- package/skills/marquee-share/src/render/template.ts +171 -0
- package/skills/marquee-share/src/upload/index.ts +11 -0
- package/skills/marquee-share/src/upload/upload.ts +191 -0
- package/skills/marquee-share/tests/cli.test.ts +281 -0
- package/skills/marquee-share/tests/config.test.ts +119 -0
- package/skills/marquee-share/tests/flow.test.ts +356 -0
- package/skills/marquee-share/tests/no-token-leak.test.ts +240 -0
- package/skills/marquee-share/tests/pkce.test.ts +121 -0
- package/skills/marquee-share/tests/session.test.ts +173 -0
- package/skills/marquee-share/tests/template.test.ts +170 -0
- package/skills/marquee-share/tests/upload.test.ts +311 -0
- package/skills/marquee-share/tsconfig.json +23 -0
- package/skills/marquee-share/vitest.config.ts +15 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Runtime configuration for the Marquee share skill.
|
|
2
|
+
//
|
|
3
|
+
// Adapted from @m-kopa/launchpad-cli src/config.ts — see
|
|
4
|
+
// auth/PROVENANCE.md. Two values determine where the skill talks to:
|
|
5
|
+
//
|
|
6
|
+
// * `resourceUrl` — base URL of the Marquee app (the
|
|
7
|
+
// Cloudflare-Access-protected Pages deployment). Discovery walks
|
|
8
|
+
// the well-known docs under this host; the upload task POSTs to
|
|
9
|
+
// `${resourceUrl}/api/uploads`.
|
|
10
|
+
// * `sessionPath` — where the OAuth session is persisted.
|
|
11
|
+
//
|
|
12
|
+
// Defaults come from the bundled prod constants. Overrides come from
|
|
13
|
+
// environment variables — useful for local dev against a preview
|
|
14
|
+
// deployment, or for the test harness.
|
|
15
|
+
//
|
|
16
|
+
// Environment overrides:
|
|
17
|
+
// * `MARQUEE_RESOURCE_URL` — overrides `resourceUrl`. Trailing
|
|
18
|
+
// slashes are trimmed so callers can do `${resourceUrl}/api/...`
|
|
19
|
+
// without worrying about double slashes. Validated at load time:
|
|
20
|
+
// must be a well-formed absolute URL that is either `https:` (any
|
|
21
|
+
// host) or `http:` restricted to loopback hosts (local dev only).
|
|
22
|
+
// * `MARQUEE_SESSION_PATH` — overrides `sessionPath`. Used by tests
|
|
23
|
+
// to point at a temp directory.
|
|
24
|
+
//
|
|
25
|
+
// A blank or whitespace-only value for either variable is treated as
|
|
26
|
+
// unset — the bundled default applies — rather than as a real
|
|
27
|
+
// override that would yield empty config.
|
|
28
|
+
|
|
29
|
+
import * as os from "node:os";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
|
|
32
|
+
/** Production Marquee app URL (Cloudflare-Access-protected). */
|
|
33
|
+
export const DEFAULT_RESOURCE_URL = "https://marquee.launchpad.m-kopa.us";
|
|
34
|
+
|
|
35
|
+
export interface SkillConfig {
|
|
36
|
+
readonly resourceUrl: string;
|
|
37
|
+
readonly sessionPath: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reads an environment override, treating a blank or whitespace-only
|
|
42
|
+
* value as unset. Returns the trimmed value, or `undefined` when the
|
|
43
|
+
* variable is absent or empty — so the caller's default applies.
|
|
44
|
+
*/
|
|
45
|
+
function readOverride(value: string | undefined): string | undefined {
|
|
46
|
+
if (value === undefined) return undefined;
|
|
47
|
+
const trimmed = value.trim();
|
|
48
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when `host` is a loopback host: `localhost`, an explicit
|
|
53
|
+
* loopback IP (`127.0.0.1`, `::1`), or a `*.localhost` subdomain.
|
|
54
|
+
* `http:` is only permitted for these, to keep the OAuth bearer token
|
|
55
|
+
* off plaintext links to arbitrary hosts.
|
|
56
|
+
*/
|
|
57
|
+
function isLoopbackHost(host: string): boolean {
|
|
58
|
+
const h = host.toLowerCase();
|
|
59
|
+
return (
|
|
60
|
+
h === "localhost" ||
|
|
61
|
+
h.endsWith(".localhost") ||
|
|
62
|
+
h === "127.0.0.1" ||
|
|
63
|
+
h === "::1" ||
|
|
64
|
+
h === "[::1]"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function loadConfig(env: NodeJS.ProcessEnv = process.env): SkillConfig {
|
|
69
|
+
const resourceUrl =
|
|
70
|
+
readOverride(env.MARQUEE_RESOURCE_URL)?.replace(/\/+$/, "") ??
|
|
71
|
+
DEFAULT_RESOURCE_URL;
|
|
72
|
+
let parsedResourceUrl: URL;
|
|
73
|
+
try {
|
|
74
|
+
parsedResourceUrl = new URL(resourceUrl);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`MARQUEE_RESOURCE_URL must be a valid absolute URL (got: ${resourceUrl})`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
// `https:` is allowed for any host. `http:` is allowed only for
|
|
81
|
+
// loopback hosts (local dev) — a non-loopback plaintext URL risks
|
|
82
|
+
// leaking the OAuth bearer token on the wire, so it is rejected.
|
|
83
|
+
if (parsedResourceUrl.protocol === "http:") {
|
|
84
|
+
if (!isLoopbackHost(parsedResourceUrl.hostname)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`MARQUEE_RESOURCE_URL must use https: for non-loopback hosts; ` +
|
|
87
|
+
`http: is permitted only for localhost / 127.0.0.1 / ::1 ` +
|
|
88
|
+
`(got: ${resourceUrl})`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
} else if (parsedResourceUrl.protocol !== "https:") {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`MARQUEE_RESOURCE_URL must use https: (or http: for loopback hosts); ` +
|
|
94
|
+
`got: ${parsedResourceUrl.protocol}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const sessionPath =
|
|
98
|
+
readOverride(env.MARQUEE_SESSION_PATH) ??
|
|
99
|
+
path.join(os.homedir(), ".marquee", "session.json");
|
|
100
|
+
return { resourceUrl, sessionPath };
|
|
101
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The single M-KOPA-branded HTML template for the Marquee share skill.
|
|
2
|
+
//
|
|
3
|
+
// DESIGN INTENT
|
|
4
|
+
// This is ONE template, not a template system. The skill is a thin
|
|
5
|
+
// uploader: an AI chat produces the inner content as HTML (per the
|
|
6
|
+
// SKILL.md guidance written in Task 5), and this module wraps that
|
|
7
|
+
// content in a clean, branded, self-contained document shell. If a
|
|
8
|
+
// different look is ever needed, the right move is to edit the
|
|
9
|
+
// `STYLE` / `SHELL_*` constants below — not to grow a theming layer.
|
|
10
|
+
//
|
|
11
|
+
// SELF-CONTAINED
|
|
12
|
+
// The produced document references no external CSS, JS, or fonts.
|
|
13
|
+
// All styling is in one inline <style> block. Marquee renders
|
|
14
|
+
// uploaded HTML inside a sandboxed iframe on a separate origin with
|
|
15
|
+
// no credentials; a document that needs the network would render
|
|
16
|
+
// broken there. System fonts only.
|
|
17
|
+
//
|
|
18
|
+
// ESCAPING
|
|
19
|
+
// Two slots are filled:
|
|
20
|
+
// * `title` — a PLAIN STRING. It is HTML-escaped before
|
|
21
|
+
// injection. A hostile title therefore cannot
|
|
22
|
+
// break out of the <title>/<h1> text context to
|
|
23
|
+
// inject markup or script into the shell.
|
|
24
|
+
// * `contentHtml` — already-rendered HTML, inserted verbatim into
|
|
25
|
+
// <body>. Treating it as HTML is the contract
|
|
26
|
+
// (the AI produces real HTML). It cannot subvert
|
|
27
|
+
// the SHELL: the shell prefix is a fixed string
|
|
28
|
+
// built before the slot, so no payload can reach
|
|
29
|
+
// back and alter the shell's own structure. The
|
|
30
|
+
// trust boundary for the content itself is
|
|
31
|
+
// Marquee's sandboxed-iframe render isolation
|
|
32
|
+
// (see the marquee repo ARCHITECTURE.md), not
|
|
33
|
+
// this skill.
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* M-KOPA brand accent. M-KOPA's brand colour is a vivid green; this is
|
|
37
|
+
* a reasonable stand-in. HUMAN: confirm the exact brand hex and tweak
|
|
38
|
+
* this one constant — everything else derives from it.
|
|
39
|
+
*/
|
|
40
|
+
const BRAND_GREEN = "#00b140";
|
|
41
|
+
/** Near-black used for body text and the footer rule. */
|
|
42
|
+
const INK = "#1a1a1a";
|
|
43
|
+
/** Default title when the caller supplies an empty string. */
|
|
44
|
+
const DEFAULT_TITLE = "Shared via Marquee";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Escape the five HTML-significant characters so a string is safe to
|
|
48
|
+
* place in element text OR a double-quoted attribute. `&` first so we
|
|
49
|
+
* never double-encode an entity we just produced.
|
|
50
|
+
*/
|
|
51
|
+
function escapeHtml(value: string): string {
|
|
52
|
+
return value
|
|
53
|
+
.replace(/&/g, "&")
|
|
54
|
+
.replace(/</g, "<")
|
|
55
|
+
.replace(/>/g, ">")
|
|
56
|
+
.replace(/"/g, """)
|
|
57
|
+
.replace(/'/g, "'");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Inputs for the branded document. */
|
|
61
|
+
export interface BrandedDocumentInput {
|
|
62
|
+
/** Human-readable title — plain text, HTML-escaped on injection. */
|
|
63
|
+
readonly title: string;
|
|
64
|
+
/** Already-rendered inner HTML — inserted verbatim into <body>. */
|
|
65
|
+
readonly contentHtml: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Inline brand stylesheet. White background, green + black accents,
|
|
70
|
+
* deliberately simple. System font stack so the document is fully
|
|
71
|
+
* self-contained.
|
|
72
|
+
*/
|
|
73
|
+
const STYLE = `
|
|
74
|
+
:root { --brand-green: ${BRAND_GREEN}; --ink: ${INK}; }
|
|
75
|
+
* { box-sizing: border-box; }
|
|
76
|
+
html, body { margin: 0; padding: 0; }
|
|
77
|
+
body {
|
|
78
|
+
background: #ffffff;
|
|
79
|
+
color: var(--ink);
|
|
80
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
81
|
+
Helvetica, Arial, sans-serif;
|
|
82
|
+
line-height: 1.6;
|
|
83
|
+
-webkit-text-size-adjust: 100%;
|
|
84
|
+
}
|
|
85
|
+
.marquee-shell {
|
|
86
|
+
max-width: 760px;
|
|
87
|
+
margin: 0 auto;
|
|
88
|
+
padding: 40px 24px 64px;
|
|
89
|
+
}
|
|
90
|
+
.marquee-header {
|
|
91
|
+
border-bottom: 3px solid var(--brand-green);
|
|
92
|
+
padding-bottom: 16px;
|
|
93
|
+
margin-bottom: 32px;
|
|
94
|
+
}
|
|
95
|
+
.marquee-eyebrow {
|
|
96
|
+
font-size: 12px;
|
|
97
|
+
font-weight: 700;
|
|
98
|
+
letter-spacing: 0.08em;
|
|
99
|
+
text-transform: uppercase;
|
|
100
|
+
color: var(--brand-green);
|
|
101
|
+
margin: 0 0 6px;
|
|
102
|
+
}
|
|
103
|
+
.marquee-title {
|
|
104
|
+
font-size: 28px;
|
|
105
|
+
font-weight: 700;
|
|
106
|
+
color: var(--ink);
|
|
107
|
+
margin: 0;
|
|
108
|
+
}
|
|
109
|
+
.marquee-content {
|
|
110
|
+
font-size: 16px;
|
|
111
|
+
}
|
|
112
|
+
.marquee-content a { color: var(--brand-green); }
|
|
113
|
+
.marquee-content pre,
|
|
114
|
+
.marquee-content code {
|
|
115
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
116
|
+
}
|
|
117
|
+
.marquee-content pre {
|
|
118
|
+
background: #f5f5f5;
|
|
119
|
+
border-left: 3px solid var(--brand-green);
|
|
120
|
+
padding: 12px 16px;
|
|
121
|
+
overflow-x: auto;
|
|
122
|
+
}
|
|
123
|
+
.marquee-footer {
|
|
124
|
+
margin-top: 48px;
|
|
125
|
+
padding-top: 16px;
|
|
126
|
+
border-top: 1px solid #e6e6e6;
|
|
127
|
+
font-size: 12px;
|
|
128
|
+
color: #6b6b6b;
|
|
129
|
+
}
|
|
130
|
+
`.trim();
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wrap already-rendered content HTML into the branded, self-contained
|
|
134
|
+
* M-KOPA document. The title is escaped; the content is inserted
|
|
135
|
+
* verbatim. Returns one complete HTML document as a string.
|
|
136
|
+
*/
|
|
137
|
+
export function renderBrandedDocument(input: BrandedDocumentInput): string {
|
|
138
|
+
const safeTitle = escapeHtml(
|
|
139
|
+
input.title.trim().length > 0 ? input.title : DEFAULT_TITLE,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// SHELL_PREFIX is a fixed string assembled entirely from the escaped
|
|
143
|
+
// title + static markup BEFORE the content slot. Because it is built
|
|
144
|
+
// first and the content is appended after, no content payload can
|
|
145
|
+
// alter the shell's structure — it is only ever bytes inside <body>.
|
|
146
|
+
const shellPrefix =
|
|
147
|
+
`<!DOCTYPE html>\n` +
|
|
148
|
+
`<html lang="en">\n` +
|
|
149
|
+
`<head>\n` +
|
|
150
|
+
`<meta charset="utf-8">\n` +
|
|
151
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1">\n` +
|
|
152
|
+
`<title>${safeTitle}</title>\n` +
|
|
153
|
+
`<style>${STYLE}</style>\n` +
|
|
154
|
+
`</head>\n` +
|
|
155
|
+
`<body>\n` +
|
|
156
|
+
`<main class="marquee-shell">\n` +
|
|
157
|
+
`<header class="marquee-header">\n` +
|
|
158
|
+
`<p class="marquee-eyebrow">M-KOPA</p>\n` +
|
|
159
|
+
`<h1 class="marquee-title">${safeTitle}</h1>\n` +
|
|
160
|
+
`</header>\n` +
|
|
161
|
+
`<article class="marquee-content">\n`;
|
|
162
|
+
|
|
163
|
+
const shellSuffix =
|
|
164
|
+
`\n</article>\n` +
|
|
165
|
+
`<footer class="marquee-footer">Shared via Marquee</footer>\n` +
|
|
166
|
+
`</main>\n` +
|
|
167
|
+
`</body>\n` +
|
|
168
|
+
`</html>\n`;
|
|
169
|
+
|
|
170
|
+
return shellPrefix + input.contentHtml + shellSuffix;
|
|
171
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Public upload surface for the Marquee share skill.
|
|
2
|
+
//
|
|
3
|
+
// This is the ONLY module later tasks (the SKILL.md entrypoint in
|
|
4
|
+
// Task 5, distribution in Task 6) should import from for the
|
|
5
|
+
// wrap + upload capability. It re-exports `shareToMarquee` — wrap
|
|
6
|
+
// content into the branded M-KOPA template, POST it to Marquee's
|
|
7
|
+
// `/api/uploads`, return the `view_url` — plus its types and the
|
|
8
|
+
// typed `MarqueeUploadError`.
|
|
9
|
+
|
|
10
|
+
export { shareToMarquee, MarqueeUploadError } from "./upload.js";
|
|
11
|
+
export type { ShareInput, ShareResult, ShareOptions } from "./upload.js";
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Wrap + upload — the core of Task 4 of the Marquee share skill.
|
|
2
|
+
//
|
|
3
|
+
// `shareToMarquee` is the one operation a caller needs:
|
|
4
|
+
// 1. Wrap already-rendered content HTML into the branded,
|
|
5
|
+
// self-contained M-KOPA document (see `../render/template.ts`).
|
|
6
|
+
// 2. Acquire a valid Cloudflare Access bearer token via the auth
|
|
7
|
+
// layer's `getValidToken` (Task 3) — silent reuse / refresh /
|
|
8
|
+
// re-login is handled there.
|
|
9
|
+
// 3. POST the document to `${resourceUrl}/api/uploads` with
|
|
10
|
+
// `Content-Type: text/html` and `Authorization: Bearer <token>`.
|
|
11
|
+
// 4. Parse and return the `view_url` from the JSON response.
|
|
12
|
+
//
|
|
13
|
+
// SECURITY POSTURE
|
|
14
|
+
// * The bearer token is placed ONLY in the `Authorization` header
|
|
15
|
+
// of the HTTPS request to Marquee. It is never logged, never put
|
|
16
|
+
// in an error message, never returned to the caller.
|
|
17
|
+
// * `MarqueeUploadError` carries the HTTP status and a generic
|
|
18
|
+
// message — never the token, never the request body.
|
|
19
|
+
//
|
|
20
|
+
// All side-effecting dependencies (`fetch`, the token provider) are
|
|
21
|
+
// injectable so the unit tests can run against a fake server without
|
|
22
|
+
// the real auth layer / browser — matching the auth `flow.test.ts`
|
|
23
|
+
// injection style.
|
|
24
|
+
|
|
25
|
+
import { loadConfig, type SkillConfig } from "../config.js";
|
|
26
|
+
import { getValidToken } from "../auth/index.js";
|
|
27
|
+
import { renderBrandedDocument } from "../render/template.js";
|
|
28
|
+
|
|
29
|
+
/** What to share: a title plus already-rendered inner HTML. */
|
|
30
|
+
export interface ShareInput {
|
|
31
|
+
/** Human-readable title — plain text, escaped into the template. */
|
|
32
|
+
readonly title: string;
|
|
33
|
+
/** Already-rendered inner HTML produced by the AI chat. */
|
|
34
|
+
readonly contentHtml: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The result of a successful share. */
|
|
38
|
+
export interface ShareResult {
|
|
39
|
+
/** The shareable URL Marquee returned. */
|
|
40
|
+
readonly viewUrl: string;
|
|
41
|
+
/** Marquee's generated upload id, when present in the response. */
|
|
42
|
+
readonly id?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Default upload-request timeout — a stalled network must not hang
|
|
46
|
+
* `share` forever. */
|
|
47
|
+
export const DEFAULT_UPLOAD_TIMEOUT_MS = 30_000;
|
|
48
|
+
|
|
49
|
+
/** Options for {@link shareToMarquee}. All injectables default. */
|
|
50
|
+
export interface ShareOptions {
|
|
51
|
+
/** Override resolved config. Defaults to `loadConfig()`. */
|
|
52
|
+
readonly config?: SkillConfig;
|
|
53
|
+
/** Injected for tests. Defaults to the global `fetch`. */
|
|
54
|
+
readonly fetcher?: typeof fetch;
|
|
55
|
+
/**
|
|
56
|
+
* Upload-request timeout in milliseconds. After this elapses the
|
|
57
|
+
* request is aborted and a `MarqueeUploadError` is thrown. Defaults
|
|
58
|
+
* to {@link DEFAULT_UPLOAD_TIMEOUT_MS}.
|
|
59
|
+
*/
|
|
60
|
+
readonly timeoutMs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Supplies the bearer token. Defaults to the auth layer's
|
|
63
|
+
* `getValidToken` (silent reuse / refresh / re-login). Injected in
|
|
64
|
+
* tests so they never touch the real auth layer.
|
|
65
|
+
*/
|
|
66
|
+
readonly tokenProvider?: () => Promise<string>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A failed Marquee upload. Carries the HTTP status when the failure
|
|
71
|
+
* was an HTTP error response; `status` is 0 for transport / parsing
|
|
72
|
+
* failures. The message is deliberately generic — it never embeds the
|
|
73
|
+
* bearer token or the upload body.
|
|
74
|
+
*/
|
|
75
|
+
export class MarqueeUploadError extends Error {
|
|
76
|
+
/** HTTP status of the failing response, or 0 if there was none. */
|
|
77
|
+
public readonly status: number;
|
|
78
|
+
|
|
79
|
+
public constructor(message: string, status: number) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "MarqueeUploadError";
|
|
82
|
+
this.status = status;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The subset of the upload response this skill consumes. */
|
|
87
|
+
interface UploadResponseShape {
|
|
88
|
+
readonly view_url?: unknown;
|
|
89
|
+
readonly id?: unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Wrap content into the branded template, upload it to Marquee, and
|
|
94
|
+
* return the shareable `view_url`.
|
|
95
|
+
*
|
|
96
|
+
* @throws {MarqueeUploadError} on a non-2xx response, a non-JSON
|
|
97
|
+
* response, or a 2xx response missing a usable `view_url`.
|
|
98
|
+
*/
|
|
99
|
+
export async function shareToMarquee(
|
|
100
|
+
input: ShareInput,
|
|
101
|
+
options: ShareOptions = {},
|
|
102
|
+
): Promise<ShareResult> {
|
|
103
|
+
const config = options.config ?? loadConfig();
|
|
104
|
+
const fetcher = options.fetcher ?? fetch;
|
|
105
|
+
const tokenProvider = options.tokenProvider ?? (() => getValidToken());
|
|
106
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS;
|
|
107
|
+
|
|
108
|
+
// 1. Wrap into the branded, self-contained document.
|
|
109
|
+
const document = renderBrandedDocument({
|
|
110
|
+
title: input.title,
|
|
111
|
+
contentHtml: input.contentHtml,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// 2. Acquire the bearer token. Held in a local only; never logged.
|
|
115
|
+
const token = await tokenProvider();
|
|
116
|
+
|
|
117
|
+
// 3. POST to `${resourceUrl}/api/uploads`.
|
|
118
|
+
const endpoint = `${config.resourceUrl}/api/uploads`;
|
|
119
|
+
let response: Response;
|
|
120
|
+
// Abort the request once `timeoutMs` elapses so a stalled network
|
|
121
|
+
// can't hang `share` indefinitely. The timer is always cleared in
|
|
122
|
+
// the `finally` so it never keeps the event loop alive.
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
125
|
+
try {
|
|
126
|
+
response = await fetcher(endpoint, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: {
|
|
129
|
+
// Marquee stores/serves text/html; the body is the wrapped
|
|
130
|
+
// branded document. `charset=utf-8` matches what Marquee pins.
|
|
131
|
+
"content-type": "text/html; charset=utf-8",
|
|
132
|
+
// The ONLY place the token travels. HTTPS to Marquee.
|
|
133
|
+
authorization: `Bearer ${token}`,
|
|
134
|
+
},
|
|
135
|
+
body: document,
|
|
136
|
+
signal: controller.signal,
|
|
137
|
+
});
|
|
138
|
+
} catch (_e) {
|
|
139
|
+
// Transport failure (DNS, TLS, offline) or an abort fired by the
|
|
140
|
+
// timeout above. `_e` may itself reference the request — do NOT
|
|
141
|
+
// fold it into the message; surface a generic, token-free error.
|
|
142
|
+
throw new MarqueeUploadError("upload request failed (network error)", 0);
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 4a. Non-2xx → typed error carrying the status (no body, no token).
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
throw new MarqueeUploadError(
|
|
150
|
+
`Marquee upload failed with HTTP ${response.status}`,
|
|
151
|
+
response.status,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4b. Parse the JSON response and extract `view_url`.
|
|
156
|
+
let parsed: unknown;
|
|
157
|
+
try {
|
|
158
|
+
parsed = await response.json();
|
|
159
|
+
} catch (_e) {
|
|
160
|
+
throw new MarqueeUploadError(
|
|
161
|
+
"Marquee upload returned a non-JSON response",
|
|
162
|
+
response.status,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A successful `JSON.parse` still admits `null`, an array, or a bare
|
|
167
|
+
// primitive (`42`, `"ok"`). Reading `.view_url` off any of those would
|
|
168
|
+
// throw a raw `TypeError` (for `null`) or silently yield `undefined`.
|
|
169
|
+
// Narrow to a non-null, non-array object FIRST so every malformed
|
|
170
|
+
// response surfaces as a typed `MarqueeUploadError`, never a stray
|
|
171
|
+
// `TypeError`.
|
|
172
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
173
|
+
throw new MarqueeUploadError(
|
|
174
|
+
"Marquee upload response was not a JSON object",
|
|
175
|
+
response.status,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const shape = parsed as UploadResponseShape;
|
|
180
|
+
if (typeof shape.view_url !== "string" || shape.view_url.length === 0) {
|
|
181
|
+
throw new MarqueeUploadError(
|
|
182
|
+
"Marquee upload response did not include a view_url",
|
|
183
|
+
response.status,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
viewUrl: shape.view_url,
|
|
189
|
+
...(typeof shape.id === "string" ? { id: shape.id } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|