@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.
Files changed (191) hide show
  1. package/CHANGELOG.md +854 -0
  2. package/README.md +109 -0
  3. package/dist/auth/browser.d.ts +18 -0
  4. package/dist/auth/browser.d.ts.map +1 -0
  5. package/dist/auth/callback-server.d.ts +24 -0
  6. package/dist/auth/callback-server.d.ts.map +1 -0
  7. package/dist/auth/discovery.d.ts +25 -0
  8. package/dist/auth/discovery.d.ts.map +1 -0
  9. package/dist/auth/flow.d.ts +39 -0
  10. package/dist/auth/flow.d.ts.map +1 -0
  11. package/dist/auth/jwt.d.ts +27 -0
  12. package/dist/auth/jwt.d.ts.map +1 -0
  13. package/dist/auth/pkce.d.ts +26 -0
  14. package/dist/auth/pkce.d.ts.map +1 -0
  15. package/dist/auth/registration.d.ts +8 -0
  16. package/dist/auth/registration.d.ts.map +1 -0
  17. package/dist/auth/session.d.ts +54 -0
  18. package/dist/auth/session.d.ts.map +1 -0
  19. package/dist/auth/token.d.ts +37 -0
  20. package/dist/auth/token.d.ts.map +1 -0
  21. package/dist/bundle/cron-bundle.d.ts +77 -0
  22. package/dist/bundle/cron-bundle.d.ts.map +1 -0
  23. package/dist/bundle/cwd-walker.d.ts +43 -0
  24. package/dist/bundle/cwd-walker.d.ts.map +1 -0
  25. package/dist/bundle/orchestrate.d.ts +51 -0
  26. package/dist/bundle/orchestrate.d.ts.map +1 -0
  27. package/dist/bundle/upload.d.ts +66 -0
  28. package/dist/bundle/upload.d.ts.map +1 -0
  29. package/dist/cli.d.ts +3 -0
  30. package/dist/cli.d.ts.map +1 -0
  31. package/dist/cli.js +9757 -0
  32. package/dist/clone/git-init.d.ts +18 -0
  33. package/dist/clone/git-init.d.ts.map +1 -0
  34. package/dist/clone/tar-extract.d.ts +59 -0
  35. package/dist/clone/tar-extract.d.ts.map +1 -0
  36. package/dist/commands/apps.d.ts +14 -0
  37. package/dist/commands/apps.d.ts.map +1 -0
  38. package/dist/commands/channel-auth.d.ts +31 -0
  39. package/dist/commands/channel-auth.d.ts.map +1 -0
  40. package/dist/commands/clone.d.ts +3 -0
  41. package/dist/commands/clone.d.ts.map +1 -0
  42. package/dist/commands/create.d.ts +27 -0
  43. package/dist/commands/create.d.ts.map +1 -0
  44. package/dist/commands/deploy-flags.d.ts +75 -0
  45. package/dist/commands/deploy-flags.d.ts.map +1 -0
  46. package/dist/commands/deploy-modes.d.ts +59 -0
  47. package/dist/commands/deploy-modes.d.ts.map +1 -0
  48. package/dist/commands/deploy.d.ts +29 -0
  49. package/dist/commands/deploy.d.ts.map +1 -0
  50. package/dist/commands/destroy.d.ts +14 -0
  51. package/dist/commands/destroy.d.ts.map +1 -0
  52. package/dist/commands/envvars.d.ts +28 -0
  53. package/dist/commands/envvars.d.ts.map +1 -0
  54. package/dist/commands/generate.d.ts +3 -0
  55. package/dist/commands/generate.d.ts.map +1 -0
  56. package/dist/commands/groups-whoami.d.ts +3 -0
  57. package/dist/commands/groups-whoami.d.ts.map +1 -0
  58. package/dist/commands/groups.d.ts +3 -0
  59. package/dist/commands/groups.d.ts.map +1 -0
  60. package/dist/commands/init.d.ts +44 -0
  61. package/dist/commands/init.d.ts.map +1 -0
  62. package/dist/commands/login.d.ts +3 -0
  63. package/dist/commands/login.d.ts.map +1 -0
  64. package/dist/commands/logout.d.ts +3 -0
  65. package/dist/commands/logout.d.ts.map +1 -0
  66. package/dist/commands/logs.d.ts +16 -0
  67. package/dist/commands/logs.d.ts.map +1 -0
  68. package/dist/commands/merge.d.ts +29 -0
  69. package/dist/commands/merge.d.ts.map +1 -0
  70. package/dist/commands/plan.d.ts +3 -0
  71. package/dist/commands/plan.d.ts.map +1 -0
  72. package/dist/commands/pull.d.ts +12 -0
  73. package/dist/commands/pull.d.ts.map +1 -0
  74. package/dist/commands/review.d.ts +22 -0
  75. package/dist/commands/review.d.ts.map +1 -0
  76. package/dist/commands/rollback.d.ts +3 -0
  77. package/dist/commands/rollback.d.ts.map +1 -0
  78. package/dist/commands/secrets-template.d.ts +3 -0
  79. package/dist/commands/secrets-template.d.ts.map +1 -0
  80. package/dist/commands/secrets.d.ts +3 -0
  81. package/dist/commands/secrets.d.ts.map +1 -0
  82. package/dist/commands/skills.d.ts +13 -0
  83. package/dist/commands/skills.d.ts.map +1 -0
  84. package/dist/commands/status.d.ts +54 -0
  85. package/dist/commands/status.d.ts.map +1 -0
  86. package/dist/commands/update.d.ts +114 -0
  87. package/dist/commands/update.d.ts.map +1 -0
  88. package/dist/commands/validate.d.ts +3 -0
  89. package/dist/commands/validate.d.ts.map +1 -0
  90. package/dist/commands/whoami.d.ts +3 -0
  91. package/dist/commands/whoami.d.ts.map +1 -0
  92. package/dist/config.d.ts +11 -0
  93. package/dist/config.d.ts.map +1 -0
  94. package/dist/deploy/apply.d.ts +29 -0
  95. package/dist/deploy/apply.d.ts.map +1 -0
  96. package/dist/deploy/dry-run.d.ts +13 -0
  97. package/dist/deploy/dry-run.d.ts.map +1 -0
  98. package/dist/deploy/git-files.d.ts +33 -0
  99. package/dist/deploy/git-files.d.ts.map +1 -0
  100. package/dist/deploy/group-pin.d.ts +66 -0
  101. package/dist/deploy/group-pin.d.ts.map +1 -0
  102. package/dist/deploy/manifest-state.d.ts +20 -0
  103. package/dist/deploy/manifest-state.d.ts.map +1 -0
  104. package/dist/deploy/manifest-status.d.ts +11 -0
  105. package/dist/deploy/manifest-status.d.ts.map +1 -0
  106. package/dist/deploy/resolve.d.ts +53 -0
  107. package/dist/deploy/resolve.d.ts.map +1 -0
  108. package/dist/deploy/rollback.d.ts +23 -0
  109. package/dist/deploy/rollback.d.ts.map +1 -0
  110. package/dist/deploy/runner.d.ts +29 -0
  111. package/dist/deploy/runner.d.ts.map +1 -0
  112. package/dist/deploy/stage-exit-codes.d.ts +41 -0
  113. package/dist/deploy/stage-exit-codes.d.ts.map +1 -0
  114. package/dist/deploy/status-polling.d.ts +37 -0
  115. package/dist/deploy/status-polling.d.ts.map +1 -0
  116. package/dist/deploy/tar-pack.d.ts +22 -0
  117. package/dist/deploy/tar-pack.d.ts.map +1 -0
  118. package/dist/detect/index.d.ts +53 -0
  119. package/dist/detect/index.d.ts.map +1 -0
  120. package/dist/dispatcher.d.ts +30 -0
  121. package/dist/dispatcher.d.ts.map +1 -0
  122. package/dist/groups/client.d.ts +62 -0
  123. package/dist/groups/client.d.ts.map +1 -0
  124. package/dist/http/api-client.d.ts +33 -0
  125. package/dist/http/api-client.d.ts.map +1 -0
  126. package/dist/http/errors.d.ts +31 -0
  127. package/dist/http/errors.d.ts.map +1 -0
  128. package/dist/manifest/load.d.ts +38 -0
  129. package/dist/manifest/load.d.ts.map +1 -0
  130. package/dist/manifest/schema.d.ts +3 -0
  131. package/dist/manifest/schema.d.ts.map +1 -0
  132. package/dist/postinstall.d.ts +3 -0
  133. package/dist/postinstall.d.ts.map +1 -0
  134. package/dist/postinstall.js +37 -0
  135. package/dist/secrets/env-parse.d.ts +19 -0
  136. package/dist/secrets/env-parse.d.ts.map +1 -0
  137. package/dist/secrets/push.d.ts +13 -0
  138. package/dist/secrets/push.d.ts.map +1 -0
  139. package/dist/secrets/set.d.ts +19 -0
  140. package/dist/secrets/set.d.ts.map +1 -0
  141. package/dist/secrets/status.d.ts +19 -0
  142. package/dist/secrets/status.d.ts.map +1 -0
  143. package/dist/types/api.d.ts +112 -0
  144. package/dist/types/api.d.ts.map +1 -0
  145. package/dist/update-notifier.d.ts +69 -0
  146. package/dist/update-notifier.d.ts.map +1 -0
  147. package/dist/version.d.ts +2 -0
  148. package/dist/version.d.ts.map +1 -0
  149. package/package.json +62 -0
  150. package/skills/README.md +100 -0
  151. package/skills/_partials/shell-contract.md +42 -0
  152. package/skills/launchpad-content-pr/SKILL.md +255 -0
  153. package/skills/launchpad-deploy/SKILL.md +415 -0
  154. package/skills/launchpad-deploy-status/SKILL.md +231 -0
  155. package/skills/launchpad-destroy/SKILL.md +317 -0
  156. package/skills/launchpad-onboard/SKILL.md +179 -0
  157. package/skills/launchpad-status/SKILL.md +263 -0
  158. package/skills/marquee-share/README.md +155 -0
  159. package/skills/marquee-share/SKILL.md +94 -0
  160. package/skills/marquee-share/SYNC.md +27 -0
  161. package/skills/marquee-share/dist/cli.js +896 -0
  162. package/skills/marquee-share/eslint.config.mjs +71 -0
  163. package/skills/marquee-share/install.sh +103 -0
  164. package/skills/marquee-share/package-lock.json +3946 -0
  165. package/skills/marquee-share/package.json +30 -0
  166. package/skills/marquee-share/src/auth/PROVENANCE.md +103 -0
  167. package/skills/marquee-share/src/auth/browser.ts +75 -0
  168. package/skills/marquee-share/src/auth/callback-server.ts +171 -0
  169. package/skills/marquee-share/src/auth/discovery.ts +171 -0
  170. package/skills/marquee-share/src/auth/flow.ts +262 -0
  171. package/skills/marquee-share/src/auth/index.ts +171 -0
  172. package/skills/marquee-share/src/auth/jwt.ts +77 -0
  173. package/skills/marquee-share/src/auth/pkce.ts +79 -0
  174. package/skills/marquee-share/src/auth/registration.ts +87 -0
  175. package/skills/marquee-share/src/auth/session.ts +205 -0
  176. package/skills/marquee-share/src/auth/token.ts +162 -0
  177. package/skills/marquee-share/src/cli.ts +246 -0
  178. package/skills/marquee-share/src/config.ts +101 -0
  179. package/skills/marquee-share/src/render/template.ts +171 -0
  180. package/skills/marquee-share/src/upload/index.ts +11 -0
  181. package/skills/marquee-share/src/upload/upload.ts +191 -0
  182. package/skills/marquee-share/tests/cli.test.ts +281 -0
  183. package/skills/marquee-share/tests/config.test.ts +119 -0
  184. package/skills/marquee-share/tests/flow.test.ts +356 -0
  185. package/skills/marquee-share/tests/no-token-leak.test.ts +240 -0
  186. package/skills/marquee-share/tests/pkce.test.ts +121 -0
  187. package/skills/marquee-share/tests/session.test.ts +173 -0
  188. package/skills/marquee-share/tests/template.test.ts +170 -0
  189. package/skills/marquee-share/tests/upload.test.ts +311 -0
  190. package/skills/marquee-share/tsconfig.json +23 -0
  191. 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, "&amp;")
54
+ .replace(/</g, "&lt;")
55
+ .replace(/>/g, "&gt;")
56
+ .replace(/"/g, "&quot;")
57
+ .replace(/'/g, "&#39;");
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
+ }