@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,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
|
+
}
|