@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,205 @@
1
+ // Persisted skill session — file-backed.
2
+ //
3
+ // Vendored from @m-kopa/launchpad-cli src/auth/session.ts — see
4
+ // PROVENANCE.md. Marquee adaptations: comments refer to
5
+ // `~/.marquee/session.json` and `MARQUEE_SESSION_PATH`; and
6
+ // `readSession` rejects non-finite / non-positive
7
+ // `accessTokenExpiresAt` values rather than type-checking alone.
8
+ // The storage logic itself is otherwise identical.
9
+ //
10
+ // Storage is a single JSON file at `~/.marquee/session.json`
11
+ // (overridable via `MARQUEE_SESSION_PATH`). The file is created with
12
+ // mode `0o600` and the parent directory with `0o700` so other users
13
+ // on a shared box can't read the access token.
14
+ //
15
+ // Why a file rather than the OS keychain (e.g. `keytar`):
16
+ // * `keytar` is a native module — adds a build-time dependency
17
+ // and cross-platform packaging headaches the skill doesn't need.
18
+ // * The threat model already trusts files in `$HOME` (same posture
19
+ // as `~/.aws/credentials`, `~/.config/gh/hosts.yml`). Mode
20
+ // `0o600` is the standard mitigation.
21
+ // If the keychain story matters later we can swap the implementation
22
+ // behind `readSession` / `writeSession` without touching callers.
23
+ //
24
+ // Schema versioning: `version: 1` is stamped into every file. A
25
+ // future migration bumps the integer; readers reject unknown
26
+ // versions rather than guessing.
27
+ //
28
+ // SECURITY: the access + refresh tokens live in this struct. They
29
+ // must never be logged or echoed. This module only ever writes them
30
+ // to the 0600 session file; nothing here goes to stdout/stderr.
31
+
32
+ import * as fs from "node:fs/promises";
33
+ import * as path from "node:path";
34
+ import { randomBytes } from "node:crypto";
35
+
36
+ export const SESSION_VERSION = 1;
37
+
38
+ export interface MarqueeSession {
39
+ readonly version: typeof SESSION_VERSION;
40
+ /** OAuth `access_token`. Sent to Marquee as
41
+ * `Authorization: Bearer <accessToken>`. */
42
+ readonly accessToken: string;
43
+ /** OAuth `refresh_token` — used to silently mint a fresh
44
+ * access token when the current one expires. */
45
+ readonly refreshToken: string;
46
+ /** Epoch milliseconds at which `accessToken` ceases to be valid.
47
+ * Computed at write-time as `Date.now() + (expires_in * 1000)`. */
48
+ readonly accessTokenExpiresAt: number;
49
+ /** OAuth client id from dynamic-client-registration. Cached so
50
+ * refreshes don't re-register. There is NO client secret — this
51
+ * is a public client. */
52
+ readonly clientId: string;
53
+ /** Token endpoint discovered at login. Cached for refreshes so
54
+ * refresh doesn't re-walk discovery. */
55
+ readonly tokenEndpoint: string;
56
+ /** RFC 8707 resource indicator (Marquee's canonical URL). Cached
57
+ * so silent refresh can re-include it without re-discovering —
58
+ * Cf Access binds the access_token's audience to this and the
59
+ * refresh grant must match. Optional in the on-disk schema so a
60
+ * session written without it still parses (those callers fall
61
+ * through to a forced re-login on the next refresh attempt). */
62
+ readonly resource?: string;
63
+ /** ISO-8601 UTC timestamp of when this session was written. Used
64
+ * for diagnostic display only. */
65
+ readonly issuedAt: string;
66
+ }
67
+
68
+ export class SessionParseError extends Error {
69
+ readonly code = "session_parse_error" as const;
70
+ }
71
+
72
+ /**
73
+ * Read the session file. Returns `null` if the file doesn't exist
74
+ * (the user hasn't logged in yet — a normal state). Throws
75
+ * `SessionParseError` for a corrupt file: better to fail loudly
76
+ * than to silently re-prompt for login when the storage is
77
+ * actually broken.
78
+ */
79
+ export async function readSession(
80
+ sessionPath: string,
81
+ ): Promise<MarqueeSession | null> {
82
+ let raw: string;
83
+ try {
84
+ raw = await fs.readFile(sessionPath, "utf8");
85
+ } catch (e) {
86
+ if (isErrno(e) && e.code === "ENOENT") return null;
87
+ throw e;
88
+ }
89
+ let parsed: unknown;
90
+ try {
91
+ parsed = JSON.parse(raw);
92
+ } catch (e) {
93
+ throw new SessionParseError(
94
+ `session file at ${sessionPath} is not valid JSON: ${describe(e)}`,
95
+ );
96
+ }
97
+ if (typeof parsed !== "object" || parsed === null) {
98
+ throw new SessionParseError(
99
+ `session file at ${sessionPath} is not an object`,
100
+ );
101
+ }
102
+ const obj = parsed as Record<string, unknown>;
103
+ if (obj.version !== SESSION_VERSION) {
104
+ throw new SessionParseError(
105
+ `unsupported session version ${String(obj.version)} at ${sessionPath}; expected ${SESSION_VERSION}`,
106
+ );
107
+ }
108
+ for (const k of [
109
+ "accessToken",
110
+ "refreshToken",
111
+ "clientId",
112
+ "tokenEndpoint",
113
+ "issuedAt",
114
+ ] as const) {
115
+ if (typeof obj[k] !== "string") {
116
+ throw new SessionParseError(
117
+ `session file at ${sessionPath}: missing or non-string ${k}`,
118
+ );
119
+ }
120
+ }
121
+ // `accessTokenExpiresAt` gates silent-refresh behaviour, so a
122
+ // type-only check is too weak: `NaN`, `±Infinity`, and non-positive
123
+ // values would all slip through and break the expiry comparison in
124
+ // `getValidAccessToken`. Reject anything that is not a positive
125
+ // finite number.
126
+ if (
127
+ typeof obj.accessTokenExpiresAt !== "number" ||
128
+ !Number.isFinite(obj.accessTokenExpiresAt) ||
129
+ obj.accessTokenExpiresAt <= 0
130
+ ) {
131
+ throw new SessionParseError(
132
+ `session file at ${sessionPath}: missing or invalid accessTokenExpiresAt`,
133
+ );
134
+ }
135
+ // `resource` is optional in the on-disk schema. Reject only
136
+ // obviously-wrong shapes (non-string) — absent is fine.
137
+ if (obj.resource !== undefined && typeof obj.resource !== "string") {
138
+ throw new SessionParseError(
139
+ `session file at ${sessionPath}: resource present but not a string`,
140
+ );
141
+ }
142
+ return obj as unknown as MarqueeSession;
143
+ }
144
+
145
+ /**
146
+ * Atomic-ish write: writes to `<path>.<unique>.tmp` then renames
147
+ * into place. The whole-directory chmod is best-effort (Windows
148
+ * ignores mode bits) — the per-file mode is the load-bearing one.
149
+ */
150
+ export async function writeSession(
151
+ sessionPath: string,
152
+ session: MarqueeSession,
153
+ ): Promise<void> {
154
+ const dir = path.dirname(sessionPath);
155
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
156
+ // chmod the directory in case it pre-existed at a wider mode.
157
+ // Best-effort — surface no error on Windows.
158
+ try {
159
+ await fs.chmod(dir, 0o700);
160
+ } catch {
161
+ /* ignore — Windows etc. */
162
+ }
163
+ // Per-write unique tmp filename. Two concurrent processes both
164
+ // refreshing tokens at the same time could otherwise stomp each
165
+ // other's `.tmp` file mid-write. pid+ts+random makes collisions
166
+ // inconceivable.
167
+ const tmp = `${sessionPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}.tmp`;
168
+ try {
169
+ await fs.writeFile(tmp, JSON.stringify(session, null, 2), {
170
+ encoding: "utf8",
171
+ mode: 0o600,
172
+ });
173
+ await fs.rename(tmp, sessionPath);
174
+ } catch (e) {
175
+ // Best-effort tmp cleanup so a failed write doesn't leak
176
+ // increasingly-stale `.tmp` files into ~/.marquee/.
177
+ await fs.unlink(tmp).catch(() => undefined);
178
+ throw e;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * `logout`: zero out the session file. Idempotent — already-absent
184
+ * file is a no-op success. Returns whether a session actually
185
+ * existed (so the caller can render the right message).
186
+ */
187
+ export async function clearSession(sessionPath: string): Promise<boolean> {
188
+ try {
189
+ await fs.unlink(sessionPath);
190
+ return true;
191
+ } catch (e) {
192
+ if (isErrno(e) && e.code === "ENOENT") return false;
193
+ throw e;
194
+ }
195
+ }
196
+
197
+ function isErrno(e: unknown): e is NodeJS.ErrnoException {
198
+ return (
199
+ e instanceof Error && typeof (e as NodeJS.ErrnoException).code === "string"
200
+ );
201
+ }
202
+
203
+ function describe(e: unknown): string {
204
+ return e instanceof Error ? e.message : String(e);
205
+ }
@@ -0,0 +1,162 @@
1
+ // Token-endpoint exchanges: code-for-token (login) and
2
+ // refresh-token-for-token (silent refresh).
3
+ //
4
+ // Vendored from @m-kopa/launchpad-cli src/auth/token.ts — see
5
+ // PROVENANCE.md. The grant exchange is generic OAuth and not
6
+ // Marquee-specific. Marquee divergence: a non-2xx response surfaces
7
+ // only the HTTP status plus the standard OAuth `error` code — the
8
+ // raw response body is never embedded in the error (leak hardening).
9
+ //
10
+ // Cloudflare Access's token endpoint speaks application/x-www-form-
11
+ // urlencoded for both grants, returns JSON. The two callers care
12
+ // about the same set of response fields (access_token,
13
+ // refresh_token, expires_in), so we centralise parsing here.
14
+ //
15
+ // Note: there is NO `client_secret` form field anywhere below. This
16
+ // is a PKCE public client — `code_verifier` is the proof.
17
+
18
+ export interface TokenResponse {
19
+ readonly accessToken: string;
20
+ readonly refreshToken: string;
21
+ /** Seconds until `accessToken` expiry, as returned by the
22
+ * server. Caller converts to absolute epoch when persisting. */
23
+ readonly expiresInSec: number;
24
+ }
25
+
26
+ export class TokenError extends Error {
27
+ readonly code = "token_error" as const;
28
+ /** Optional HTTP status — present only for non-2xx responses, not
29
+ * for network errors. Lets the login flow distinguish "user
30
+ * denied at the IdP" from "network is down". */
31
+ readonly httpStatus?: number;
32
+ constructor(message: string, httpStatus?: number) {
33
+ super(message);
34
+ this.name = "TokenError";
35
+ if (httpStatus !== undefined) this.httpStatus = httpStatus;
36
+ }
37
+ }
38
+
39
+ export async function exchangeCodeForTokens(
40
+ params: {
41
+ readonly tokenEndpoint: string;
42
+ readonly clientId: string;
43
+ readonly code: string;
44
+ readonly codeVerifier: string;
45
+ readonly redirectUri: string;
46
+ /** RFC 8707 resource indicator. Cf Access binds the resulting
47
+ * access_token's audience to this value; Marquee's JWT
48
+ * validation MUST see a matching `aud` claim. */
49
+ readonly resource: string;
50
+ },
51
+ fetcher: typeof fetch = fetch,
52
+ ): Promise<TokenResponse> {
53
+ const form = new URLSearchParams({
54
+ grant_type: "authorization_code",
55
+ client_id: params.clientId,
56
+ code: params.code,
57
+ code_verifier: params.codeVerifier,
58
+ redirect_uri: params.redirectUri,
59
+ resource: params.resource,
60
+ });
61
+ return postTokenForm(params.tokenEndpoint, form, fetcher);
62
+ }
63
+
64
+ export async function refreshTokens(
65
+ params: {
66
+ readonly tokenEndpoint: string;
67
+ readonly clientId: string;
68
+ readonly refreshToken: string;
69
+ /** RFC 8707 resource indicator — same posture as
70
+ * exchangeCodeForTokens. The refreshed access_token's audience
71
+ * must match what the upload call targets. */
72
+ readonly resource: string;
73
+ },
74
+ fetcher: typeof fetch = fetch,
75
+ ): Promise<TokenResponse> {
76
+ const form = new URLSearchParams({
77
+ grant_type: "refresh_token",
78
+ client_id: params.clientId,
79
+ refresh_token: params.refreshToken,
80
+ resource: params.resource,
81
+ });
82
+ return postTokenForm(params.tokenEndpoint, form, fetcher);
83
+ }
84
+
85
+ async function postTokenForm(
86
+ tokenEndpoint: string,
87
+ form: URLSearchParams,
88
+ fetcher: typeof fetch,
89
+ ): Promise<TokenResponse> {
90
+ let res: Response;
91
+ try {
92
+ res = await fetcher(tokenEndpoint, {
93
+ method: "POST",
94
+ headers: {
95
+ "content-type": "application/x-www-form-urlencoded",
96
+ accept: "application/json",
97
+ },
98
+ body: form.toString(),
99
+ });
100
+ } catch (e) {
101
+ throw new TokenError(`token endpoint: network error: ${describe(e)}`);
102
+ }
103
+ if (!res.ok) {
104
+ // Do NOT fold the raw response body into the error message. An
105
+ // upstream / proxy that reflects request data could otherwise
106
+ // leak secrets into user-visible errors. Surface the HTTP status
107
+ // plus, when the body is well-formed OAuth JSON, only the short
108
+ // standard `error` code (RFC 6749 §5.2) — never free-form text.
109
+ const detail = await res.text().catch(() => "");
110
+ let oauthError = "";
111
+ try {
112
+ const parsed = JSON.parse(detail) as { error?: unknown };
113
+ if (typeof parsed.error === "string") {
114
+ oauthError = ` (${parsed.error})`;
115
+ }
116
+ } catch {
117
+ // Non-JSON body — ignore it entirely.
118
+ }
119
+ throw new TokenError(
120
+ `token endpoint ${tokenEndpoint} returned HTTP ${res.status}${oauthError}`,
121
+ res.status,
122
+ );
123
+ }
124
+ let parsed: unknown;
125
+ try {
126
+ parsed = await res.json();
127
+ } catch (e) {
128
+ throw new TokenError(`token endpoint: non-JSON response: ${describe(e)}`);
129
+ }
130
+ if (parsed === null || typeof parsed !== "object") {
131
+ throw new TokenError(`token endpoint: response is not an object`);
132
+ }
133
+ const obj = parsed as Record<string, unknown>;
134
+ if (typeof obj.access_token !== "string") {
135
+ throw new TokenError(`token endpoint: response missing access_token`);
136
+ }
137
+ if (typeof obj.refresh_token !== "string") {
138
+ throw new TokenError(`token endpoint: response missing refresh_token`);
139
+ }
140
+ // `Number.isFinite(NaN)` and `Number.isFinite(±Infinity)` both
141
+ // return false, so this single guard covers all the pathological
142
+ // numeric shapes. ≤ 0 would mean "already expired".
143
+ const expiresIn = obj.expires_in;
144
+ if (
145
+ typeof expiresIn !== "number" ||
146
+ !Number.isFinite(expiresIn) ||
147
+ expiresIn <= 0
148
+ ) {
149
+ throw new TokenError(
150
+ `token endpoint: response missing positive finite numeric expires_in (got ${String(expiresIn)})`,
151
+ );
152
+ }
153
+ return {
154
+ accessToken: obj.access_token,
155
+ refreshToken: obj.refresh_token,
156
+ expiresInSec: expiresIn,
157
+ };
158
+ }
159
+
160
+ function describe(e: unknown): string {
161
+ return e instanceof Error ? e.message : String(e);
162
+ }
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ // CLI entrypoint for the Marquee share skill — Task 5 of M-960.
3
+ //
4
+ // SHEBANG / RUNTIME — this is a TypeScript source file. It is never
5
+ // executed directly: the dev loop runs it through `tsx` via the npm
6
+ // scripts (`tsx` is invoked explicitly and ignores this shebang), and
7
+ // the distributed/installed skill runs the bundled `dist/cli.js`
8
+ // (produced by `npm run build`) under plain Node. The `#!/usr/bin/env
9
+ // node` line is carried verbatim by `bun build` into `dist/cli.js`,
10
+ // where it is the operative shebang — so `dist/cli.js` is directly
11
+ // executable with only Node 22+ on PATH, no `tsx`, no `node_modules`.
12
+ //
13
+ // This module WIRES the auth layer (`src/auth`) and the wrap+upload
14
+ // layer (`src/upload`) into a single command with three subcommands:
15
+ //
16
+ // * `login` — run the auth layer's interactive browser PKCE login.
17
+ // * `logout` — clear the cached session.
18
+ // * `share` — read a complete inner-content HTML document (from a
19
+ // file argument or stdin), wrap+upload it to Marquee,
20
+ // and print ONLY the resulting `view_url` to stdout so
21
+ // the calling Claude Code skill can capture it.
22
+ //
23
+ // DESIGN — TESTABLE CORE
24
+ // `run()` is a pure-ish dispatcher: every side-effecting dependency
25
+ // (argv, stdin, the filesystem, the auth/upload operations, and the
26
+ // two output streams) is injected via `RunDeps`. `main()` at the
27
+ // bottom supplies the real implementations and is the only part that
28
+ // touches the process. Tests drive `run()` with fakes — they never
29
+ // open a browser, touch the network, or read the real session file.
30
+ // This mirrors the injection style of `tests/flow.test.ts` and
31
+ // `tests/upload.test.ts`.
32
+ //
33
+ // SECURITY POSTURE
34
+ // * The bearer token never appears here. `share` calls
35
+ // `shareToMarquee`, which holds the token in-process and puts it
36
+ // ONLY in an Authorization header. This CLI prints exactly one
37
+ // thing on the success path: the `view_url`.
38
+ // * Diagnostics (auth URLs, status lines) go to STDERR. stdout is
39
+ // reserved for the `view_url` alone, so a caller doing
40
+ // `marquee-share share < doc.html` gets a clean, capturable URL.
41
+ // * Errors are surfaced as a short generic message on stderr plus a
42
+ // non-zero exit code — never the token, never a raw stack.
43
+
44
+ import * as fs from "node:fs/promises";
45
+ import { pathToFileURL } from "node:url";
46
+ import { login, logout } from "./auth/index.js";
47
+ import { shareToMarquee } from "./upload/index.js";
48
+
49
+ /** A line sink — stdout or stderr. Injected so tests can capture. */
50
+ export type LineSink = (line: string) => void;
51
+
52
+ /** Reads the whole of stdin as a UTF-8 string. Injected for tests. */
53
+ export type StdinReader = () => Promise<string>;
54
+
55
+ /**
56
+ * The injectable surface of {@link run}. `main()` supplies the real
57
+ * implementations; tests supply fakes.
58
+ */
59
+ export interface RunDeps {
60
+ /** Argument vector WITHOUT `node` / script path — i.e. the
61
+ * equivalent of `process.argv.slice(2)`. */
62
+ readonly argv: readonly string[];
63
+ /** Writes a line to stdout. The `view_url` is the only thing the
64
+ * success path ever sends here. */
65
+ readonly stdout: LineSink;
66
+ /** Writes a diagnostic line to stderr. */
67
+ readonly stderr: LineSink;
68
+ /** Reads all of stdin — used by `share` when no file arg is given. */
69
+ readonly readStdin: StdinReader;
70
+ /** Reads a UTF-8 file — used by `share` with a path argument.
71
+ * Defaults to `node:fs` in `main()`. */
72
+ readonly readFile: (path: string) => Promise<string>;
73
+ /** The auth login operation. Defaults to the real `login`. */
74
+ readonly login: typeof login;
75
+ /** The auth logout operation. Defaults to the real `logout`. */
76
+ readonly logout: typeof logout;
77
+ /** Wrap+upload. Defaults to the real `shareToMarquee`. */
78
+ readonly shareToMarquee: typeof shareToMarquee;
79
+ }
80
+
81
+ const USAGE = `marquee-share — share an AI-chat result to Marquee.
82
+
83
+ Usage:
84
+ marquee-share login Authenticate with Marquee (one-time browser login).
85
+ marquee-share logout Clear the cached Marquee session.
86
+ marquee-share share [file] Wrap an HTML document, upload it, print the view URL.
87
+ Reads the document from [file], or from stdin if omitted.
88
+ --title <text> Title for the branded document (default: "Shared via Marquee").
89
+
90
+ The 'share' command prints ONLY the resulting view URL to stdout.`;
91
+
92
+ /**
93
+ * Parsed form of the `share` subcommand's arguments.
94
+ */
95
+ interface ShareArgs {
96
+ /** Path to read the inner HTML from, or `undefined` for stdin. */
97
+ readonly file?: string;
98
+ /** Document title. */
99
+ readonly title: string;
100
+ }
101
+
102
+ /**
103
+ * Parse the args that follow `share`. Recognises `--title <text>`
104
+ * (anywhere) and at most one positional file path. Throws a plain
105
+ * `Error` with a usage-friendly message on misuse.
106
+ */
107
+ function parseShareArgs(args: readonly string[]): ShareArgs {
108
+ let title = "Shared via Marquee";
109
+ let file: string | undefined;
110
+ for (let i = 0; i < args.length; i++) {
111
+ const arg = args[i] as string;
112
+ if (arg === "--title") {
113
+ const value = args[i + 1];
114
+ if (value === undefined) {
115
+ throw new Error("--title requires a value");
116
+ }
117
+ title = value;
118
+ i++;
119
+ continue;
120
+ }
121
+ if (arg.startsWith("--title=")) {
122
+ title = arg.slice("--title=".length);
123
+ continue;
124
+ }
125
+ if (arg.startsWith("-")) {
126
+ throw new Error(`unknown option: ${arg}`);
127
+ }
128
+ if (file !== undefined) {
129
+ throw new Error("share accepts at most one file argument");
130
+ }
131
+ file = arg;
132
+ }
133
+ return file !== undefined ? { file, title } : { title };
134
+ }
135
+
136
+ /**
137
+ * Run the `share` subcommand: read the inner content HTML, wrap +
138
+ * upload it, and print the `view_url`.
139
+ *
140
+ * The auth layer's `getValidToken` (used inside `shareToMarquee`)
141
+ * handles a missing session by running an interactive browser login
142
+ * itself, so a first run with no session "just works" — no special
143
+ * orchestration needed here.
144
+ */
145
+ async function runShare(
146
+ deps: RunDeps,
147
+ rest: readonly string[],
148
+ ): Promise<number> {
149
+ const { file, title } = parseShareArgs(rest);
150
+
151
+ const contentHtml =
152
+ file !== undefined ? await deps.readFile(file) : await deps.readStdin();
153
+
154
+ if (contentHtml.trim().length === 0) {
155
+ deps.stderr("error: no HTML content to share (input was empty)");
156
+ return 1;
157
+ }
158
+
159
+ const result = await deps.shareToMarquee({ title, contentHtml });
160
+ // The ONLY thing the success path writes to stdout — a clean,
161
+ // capturable URL. No token, no surrounding prose.
162
+ deps.stdout(result.viewUrl);
163
+ return 0;
164
+ }
165
+
166
+ /**
167
+ * Dispatch a parsed argv to a subcommand. Returns a process exit code
168
+ * (0 success, non-zero failure). Never throws for an expected failure
169
+ * — failures are reported on `stderr` and reflected in the code.
170
+ */
171
+ export async function run(deps: RunDeps): Promise<number> {
172
+ const [subcommand, ...rest] = deps.argv;
173
+
174
+ if (subcommand === undefined || subcommand === "--help" || subcommand === "-h") {
175
+ deps.stderr(USAGE);
176
+ // No subcommand at all is a misuse; an explicit --help is not.
177
+ return subcommand === undefined ? 1 : 0;
178
+ }
179
+
180
+ try {
181
+ switch (subcommand) {
182
+ case "login": {
183
+ await deps.login({ onDiagnostic: deps.stderr });
184
+ return 0;
185
+ }
186
+ case "logout": {
187
+ await deps.logout({ onDiagnostic: deps.stderr });
188
+ return 0;
189
+ }
190
+ case "share": {
191
+ return await runShare(deps, rest);
192
+ }
193
+ default: {
194
+ deps.stderr(`error: unknown command "${subcommand}"`);
195
+ deps.stderr(USAGE);
196
+ return 1;
197
+ }
198
+ }
199
+ } catch (e) {
200
+ // Surface a short, generic message — never a raw stack, never a
201
+ // token. `shareToMarquee` already strips secrets from its errors;
202
+ // for anything else we print only `error.message`.
203
+ const message = e instanceof Error ? e.message : String(e);
204
+ deps.stderr(`error: ${message}`);
205
+ return 1;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Real entrypoint: wires the production dependencies and exits the
211
+ * process with the resolved code. Kept tiny and untested — all logic
212
+ * lives in `run()`.
213
+ */
214
+ async function main(): Promise<void> {
215
+ const code = await run({
216
+ argv: process.argv.slice(2),
217
+ stdout: (line) => process.stdout.write(`${line}\n`),
218
+ stderr: (line) => process.stderr.write(`${line}\n`),
219
+ readStdin: async () => {
220
+ const chunks: Buffer[] = [];
221
+ for await (const chunk of process.stdin) {
222
+ chunks.push(Buffer.from(chunk));
223
+ }
224
+ return Buffer.concat(chunks).toString("utf8");
225
+ },
226
+ readFile: (path) => fs.readFile(path, "utf8"),
227
+ login,
228
+ logout,
229
+ shareToMarquee,
230
+ });
231
+ process.exitCode = code;
232
+ }
233
+
234
+ // Run only when invoked as a program (not when imported by a test).
235
+ // `import.meta.url` ends with the script path; `process.argv[1]` is
236
+ // the invoked script. Comparing them keeps the module import-safe.
237
+ // `pathToFileURL` builds a correct file:// URL across platforms — it
238
+ // percent-encodes special characters and handles Windows drive paths
239
+ // and backslashes, which a hand-built `file://${path}` string does not.
240
+ const invokedDirectly =
241
+ process.argv[1] !== undefined &&
242
+ import.meta.url === pathToFileURL(process.argv[1]).href;
243
+
244
+ if (invokedDirectly) {
245
+ void main();
246
+ }