@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,281 @@
|
|
|
1
|
+
// Tests for the CLI dispatcher of the Marquee share skill (Task 5).
|
|
2
|
+
//
|
|
3
|
+
// `run()` from `src/cli.ts` is the testable core: every side effect
|
|
4
|
+
// (argv, stdin, the filesystem, the auth/upload operations, and the
|
|
5
|
+
// two output streams) is injected. These tests drive `run()` with
|
|
6
|
+
// fakes only — they never open a browser, touch the network, or read
|
|
7
|
+
// the real session file. This matches the injection style of
|
|
8
|
+
// `tests/flow.test.ts` and `tests/upload.test.ts`.
|
|
9
|
+
//
|
|
10
|
+
// What is pinned:
|
|
11
|
+
// * Subcommand routing — `login`/`logout`/`share` reach the right
|
|
12
|
+
// operation; unknown / missing commands print usage + fail.
|
|
13
|
+
// * The `share` path reads its document (from a file arg AND from
|
|
14
|
+
// stdin), wraps+uploads it, and prints ONLY the `view_url` to
|
|
15
|
+
// stdout — nothing else on the success path.
|
|
16
|
+
// * Errors from the upload layer surface as a clean stderr line and
|
|
17
|
+
// a non-zero exit code, never a stack, never a token.
|
|
18
|
+
|
|
19
|
+
import { describe, expect, test, vi } from "vitest";
|
|
20
|
+
import { run, type RunDeps } from "../src/cli.js";
|
|
21
|
+
import type { ShareInput, ShareResult } from "../src/upload/index.js";
|
|
22
|
+
|
|
23
|
+
const VIEW_URL = "https://marquee.launchpad.m-kopa.us/v/abc123def456ghi7";
|
|
24
|
+
|
|
25
|
+
/** Captures everything written to the two line sinks. */
|
|
26
|
+
interface Sinks {
|
|
27
|
+
out: string[];
|
|
28
|
+
err: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a `RunDeps` with safe fakes. Every operation defaults to a
|
|
33
|
+
* spy that records its call; individual tests override what they
|
|
34
|
+
* care about. Nothing here touches the network, browser, or disk.
|
|
35
|
+
*/
|
|
36
|
+
function makeDeps(
|
|
37
|
+
argv: readonly string[],
|
|
38
|
+
overrides: Partial<RunDeps> = {},
|
|
39
|
+
): { deps: RunDeps; sinks: Sinks } {
|
|
40
|
+
const sinks: Sinks = { out: [], err: [] };
|
|
41
|
+
const deps: RunDeps = {
|
|
42
|
+
argv,
|
|
43
|
+
stdout: (line) => sinks.out.push(line),
|
|
44
|
+
stderr: (line) => sinks.err.push(line),
|
|
45
|
+
readStdin: vi.fn(async () => ""),
|
|
46
|
+
readFile: vi.fn(async () => ""),
|
|
47
|
+
login: vi.fn(async () => ({
|
|
48
|
+
version: 1 as const,
|
|
49
|
+
accessToken: "x",
|
|
50
|
+
refreshToken: "y",
|
|
51
|
+
accessTokenExpiresAt: Date.now() + 1000,
|
|
52
|
+
clientId: "cid",
|
|
53
|
+
tokenEndpoint: "https://auth.example/token",
|
|
54
|
+
resource: "https://marquee.launchpad.m-kopa.us",
|
|
55
|
+
issuedAt: new Date().toISOString(),
|
|
56
|
+
})),
|
|
57
|
+
logout: vi.fn(async () => true),
|
|
58
|
+
shareToMarquee: vi.fn(
|
|
59
|
+
async (_input: ShareInput): Promise<ShareResult> => ({
|
|
60
|
+
viewUrl: VIEW_URL,
|
|
61
|
+
id: "abc123def456ghi7",
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
return { deps, sinks };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("run — subcommand routing", () => {
|
|
70
|
+
test("`login` invokes the auth login operation and exits 0", async () => {
|
|
71
|
+
const { deps } = makeDeps(["login"]);
|
|
72
|
+
const code = await run(deps);
|
|
73
|
+
expect(code).toBe(0);
|
|
74
|
+
expect(deps.login).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(deps.logout).not.toHaveBeenCalled();
|
|
76
|
+
expect(deps.shareToMarquee).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("`logout` invokes the auth logout operation and exits 0", async () => {
|
|
80
|
+
const { deps } = makeDeps(["logout"]);
|
|
81
|
+
const code = await run(deps);
|
|
82
|
+
expect(code).toBe(0);
|
|
83
|
+
expect(deps.logout).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(deps.login).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("`share` invokes the wrap+upload operation", async () => {
|
|
88
|
+
const { deps } = makeDeps(["share"], {
|
|
89
|
+
readStdin: vi.fn(async () => "<p>hello</p>"),
|
|
90
|
+
});
|
|
91
|
+
const code = await run(deps);
|
|
92
|
+
expect(code).toBe(0);
|
|
93
|
+
expect(deps.shareToMarquee).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an unknown command prints an error + usage and exits non-zero", async () => {
|
|
97
|
+
const { deps, sinks } = makeDeps(["frobnicate"]);
|
|
98
|
+
const code = await run(deps);
|
|
99
|
+
expect(code).toBe(1);
|
|
100
|
+
expect(sinks.err.join("\n")).toContain("unknown command");
|
|
101
|
+
expect(sinks.err.join("\n")).toContain("Usage:");
|
|
102
|
+
expect(sinks.out).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("no command at all prints usage and exits non-zero", async () => {
|
|
106
|
+
const { deps, sinks } = makeDeps([]);
|
|
107
|
+
const code = await run(deps);
|
|
108
|
+
expect(code).toBe(1);
|
|
109
|
+
expect(sinks.err.join("\n")).toContain("Usage:");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("`--help` prints usage and exits 0", async () => {
|
|
113
|
+
const { deps, sinks } = makeDeps(["--help"]);
|
|
114
|
+
const code = await run(deps);
|
|
115
|
+
expect(code).toBe(0);
|
|
116
|
+
expect(sinks.err.join("\n")).toContain("Usage:");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("run — the share path reads input and prints the view_url", () => {
|
|
121
|
+
test("reads the document from stdin when no file arg is given", async () => {
|
|
122
|
+
const readStdin = vi.fn(async () => "<h1>From stdin</h1>");
|
|
123
|
+
const readFile = vi.fn(async () => "should-not-be-read");
|
|
124
|
+
const { deps } = makeDeps(["share"], { readStdin, readFile });
|
|
125
|
+
await run(deps);
|
|
126
|
+
expect(readStdin).toHaveBeenCalledTimes(1);
|
|
127
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
128
|
+
expect(deps.shareToMarquee).toHaveBeenCalledWith(
|
|
129
|
+
expect.objectContaining({ contentHtml: "<h1>From stdin</h1>" }),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("reads the document from a file argument when one is given", async () => {
|
|
134
|
+
const readStdin = vi.fn(async () => "should-not-be-read");
|
|
135
|
+
const readFile = vi.fn(async () => "<h1>From file</h1>");
|
|
136
|
+
const { deps } = makeDeps(["share", "report.html"], {
|
|
137
|
+
readStdin,
|
|
138
|
+
readFile,
|
|
139
|
+
});
|
|
140
|
+
await run(deps);
|
|
141
|
+
expect(readFile).toHaveBeenCalledWith("report.html");
|
|
142
|
+
expect(readStdin).not.toHaveBeenCalled();
|
|
143
|
+
expect(deps.shareToMarquee).toHaveBeenCalledWith(
|
|
144
|
+
expect.objectContaining({ contentHtml: "<h1>From file</h1>" }),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("prints ONLY the view_url to stdout on success", async () => {
|
|
149
|
+
const { deps, sinks } = makeDeps(["share"], {
|
|
150
|
+
readStdin: vi.fn(async () => "<p>x</p>"),
|
|
151
|
+
});
|
|
152
|
+
const code = await run(deps);
|
|
153
|
+
expect(code).toBe(0);
|
|
154
|
+
// Exactly one line on stdout, and it is the URL — nothing else.
|
|
155
|
+
expect(sinks.out).toEqual([VIEW_URL]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("passes a --title through to the upload layer", async () => {
|
|
159
|
+
const { deps } = makeDeps(["share", "--title", "Quarterly Report"], {
|
|
160
|
+
readStdin: vi.fn(async () => "<p>x</p>"),
|
|
161
|
+
});
|
|
162
|
+
await run(deps);
|
|
163
|
+
expect(deps.shareToMarquee).toHaveBeenCalledWith(
|
|
164
|
+
expect.objectContaining({ title: "Quarterly Report" }),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("--title=value form is also accepted", async () => {
|
|
169
|
+
const { deps } = makeDeps(["share", "--title=Inline Title", "doc.html"], {
|
|
170
|
+
readFile: vi.fn(async () => "<p>x</p>"),
|
|
171
|
+
});
|
|
172
|
+
await run(deps);
|
|
173
|
+
expect(deps.shareToMarquee).toHaveBeenCalledWith(
|
|
174
|
+
expect.objectContaining({
|
|
175
|
+
title: "Inline Title",
|
|
176
|
+
contentHtml: "<p>x</p>",
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("defaults the title when --title is omitted", async () => {
|
|
182
|
+
const { deps } = makeDeps(["share"], {
|
|
183
|
+
readStdin: vi.fn(async () => "<p>x</p>"),
|
|
184
|
+
});
|
|
185
|
+
await run(deps);
|
|
186
|
+
expect(deps.shareToMarquee).toHaveBeenCalledWith(
|
|
187
|
+
expect.objectContaining({ title: "Shared via Marquee" }),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("empty input is rejected before any upload is attempted", async () => {
|
|
192
|
+
const { deps, sinks } = makeDeps(["share"], {
|
|
193
|
+
readStdin: vi.fn(async () => " \n "),
|
|
194
|
+
});
|
|
195
|
+
const code = await run(deps);
|
|
196
|
+
expect(code).toBe(1);
|
|
197
|
+
expect(deps.shareToMarquee).not.toHaveBeenCalled();
|
|
198
|
+
expect(sinks.err.join("\n")).toContain("empty");
|
|
199
|
+
expect(sinks.out).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("run — errors surface cleanly", () => {
|
|
204
|
+
test("an upload failure exits non-zero with a stderr message, no stdout", async () => {
|
|
205
|
+
const { deps, sinks } = makeDeps(["share"], {
|
|
206
|
+
readStdin: vi.fn(async () => "<p>x</p>"),
|
|
207
|
+
shareToMarquee: vi.fn(async () => {
|
|
208
|
+
throw new Error("Marquee upload failed with HTTP 413");
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
const code = await run(deps);
|
|
212
|
+
expect(code).toBe(1);
|
|
213
|
+
expect(sinks.out).toHaveLength(0);
|
|
214
|
+
expect(sinks.err.join("\n")).toContain("error:");
|
|
215
|
+
expect(sinks.err.join("\n")).toContain("HTTP 413");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a missing file surfaces the read error cleanly", async () => {
|
|
219
|
+
const { deps, sinks } = makeDeps(["share", "nope.html"], {
|
|
220
|
+
readFile: vi.fn(async () => {
|
|
221
|
+
throw new Error("ENOENT: no such file or directory, open 'nope.html'");
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
const code = await run(deps);
|
|
225
|
+
expect(code).toBe(1);
|
|
226
|
+
expect(sinks.err.join("\n")).toContain("error:");
|
|
227
|
+
expect(deps.shareToMarquee).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("a thrown error never leaks a token-shaped string to any sink", async () => {
|
|
231
|
+
const secret = "SENTINEL-CLI-TOKEN-cafebabe";
|
|
232
|
+
const { deps, sinks } = makeDeps(["share"], {
|
|
233
|
+
readStdin: vi.fn(async () => "<p>x</p>"),
|
|
234
|
+
// A misbehaving dependency whose error carries the secret in
|
|
235
|
+
// fields the CLI must NOT surface — the `stack` and a custom
|
|
236
|
+
// property — while its `message` stays clean. The CLI prints
|
|
237
|
+
// only `error.message`; this test pins that it does not ALSO
|
|
238
|
+
// dump the error object, its stack, or its extra properties.
|
|
239
|
+
// (The real upload layer already strips secrets from messages
|
|
240
|
+
// — see upload.test.ts.) If the CLI ever widened to print the
|
|
241
|
+
// whole error, the sentinel would leak and this test would
|
|
242
|
+
// fail — which it could not do when the error carried no
|
|
243
|
+
// sentinel at all.
|
|
244
|
+
shareToMarquee: vi.fn(async () => {
|
|
245
|
+
const err = new Error("upload failed") as Error & {
|
|
246
|
+
tokenContext?: string;
|
|
247
|
+
};
|
|
248
|
+
err.stack = `Error: upload failed\n at leak (${secret})`;
|
|
249
|
+
err.tokenContext = `Bearer ${secret}`;
|
|
250
|
+
throw err;
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
await run(deps);
|
|
254
|
+
const all = [...sinks.out, ...sinks.err].join("\n");
|
|
255
|
+
expect(all).not.toContain(secret);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("--title without a value is a clean usage error", async () => {
|
|
259
|
+
const { deps, sinks } = makeDeps(["share", "--title"], {
|
|
260
|
+
readStdin: vi.fn(async () => "<p>x</p>"),
|
|
261
|
+
});
|
|
262
|
+
const code = await run(deps);
|
|
263
|
+
expect(code).toBe(1);
|
|
264
|
+
expect(sinks.err.join("\n")).toContain("--title requires a value");
|
|
265
|
+
expect(deps.shareToMarquee).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("an unknown option to share is rejected", async () => {
|
|
269
|
+
const { deps, sinks } = makeDeps(["share", "--bogus"]);
|
|
270
|
+
const code = await run(deps);
|
|
271
|
+
expect(code).toBe(1);
|
|
272
|
+
expect(sinks.err.join("\n")).toContain("unknown option");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a second positional file argument is rejected", async () => {
|
|
276
|
+
const { deps, sinks } = makeDeps(["share", "a.html", "b.html"]);
|
|
277
|
+
const code = await run(deps);
|
|
278
|
+
expect(code).toBe(1);
|
|
279
|
+
expect(sinks.err.join("\n")).toContain("at most one file");
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Config-loading tests for the Marquee share skill.
|
|
2
|
+
//
|
|
3
|
+
// Covers: the default resource URL, env overrides, trailing-slash
|
|
4
|
+
// trimming, the URL-scheme policy (https any host; http loopback
|
|
5
|
+
// only), and the blank/whitespace-only env override fall-back.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { loadConfig, DEFAULT_RESOURCE_URL } from "../src/config.js";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SESSION_PATH = path.join(
|
|
13
|
+
os.homedir(),
|
|
14
|
+
".marquee",
|
|
15
|
+
"session.json",
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
describe("loadConfig — defaults", () => {
|
|
19
|
+
test("uses bundled defaults when no env vars are set", () => {
|
|
20
|
+
const config = loadConfig({});
|
|
21
|
+
expect(config.resourceUrl).toBe(DEFAULT_RESOURCE_URL);
|
|
22
|
+
expect(config.sessionPath).toBe(DEFAULT_SESSION_PATH);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("loadConfig — overrides", () => {
|
|
27
|
+
test("MARQUEE_RESOURCE_URL overrides the resource URL", () => {
|
|
28
|
+
const config = loadConfig({
|
|
29
|
+
MARQUEE_RESOURCE_URL: "https://preview.example.com",
|
|
30
|
+
});
|
|
31
|
+
expect(config.resourceUrl).toBe("https://preview.example.com");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("trims trailing slashes from the resource URL", () => {
|
|
35
|
+
const config = loadConfig({
|
|
36
|
+
MARQUEE_RESOURCE_URL: "https://preview.example.com///",
|
|
37
|
+
});
|
|
38
|
+
expect(config.resourceUrl).toBe("https://preview.example.com");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("MARQUEE_SESSION_PATH overrides the session path", () => {
|
|
42
|
+
const config = loadConfig({ MARQUEE_SESSION_PATH: "/tmp/sess.json" });
|
|
43
|
+
expect(config.sessionPath).toBe("/tmp/sess.json");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("loadConfig — blank env overrides fall back to defaults", () => {
|
|
48
|
+
test("empty MARQUEE_RESOURCE_URL is treated as unset", () => {
|
|
49
|
+
expect(loadConfig({ MARQUEE_RESOURCE_URL: "" }).resourceUrl).toBe(
|
|
50
|
+
DEFAULT_RESOURCE_URL,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("whitespace-only MARQUEE_RESOURCE_URL is treated as unset", () => {
|
|
55
|
+
expect(loadConfig({ MARQUEE_RESOURCE_URL: " \t " }).resourceUrl).toBe(
|
|
56
|
+
DEFAULT_RESOURCE_URL,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("empty MARQUEE_SESSION_PATH is treated as unset", () => {
|
|
61
|
+
expect(loadConfig({ MARQUEE_SESSION_PATH: "" }).sessionPath).toBe(
|
|
62
|
+
DEFAULT_SESSION_PATH,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("whitespace-only MARQUEE_SESSION_PATH is treated as unset", () => {
|
|
67
|
+
expect(loadConfig({ MARQUEE_SESSION_PATH: " " }).sessionPath).toBe(
|
|
68
|
+
DEFAULT_SESSION_PATH,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a surrounding-whitespace value is trimmed, not discarded", () => {
|
|
73
|
+
const config = loadConfig({
|
|
74
|
+
MARQUEE_RESOURCE_URL: " https://preview.example.com ",
|
|
75
|
+
});
|
|
76
|
+
expect(config.resourceUrl).toBe("https://preview.example.com");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("loadConfig — URL scheme policy", () => {
|
|
81
|
+
test("accepts https: for an arbitrary host", () => {
|
|
82
|
+
expect(
|
|
83
|
+
loadConfig({ MARQUEE_RESOURCE_URL: "https://marquee.example.com" })
|
|
84
|
+
.resourceUrl,
|
|
85
|
+
).toBe("https://marquee.example.com");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test.each([
|
|
89
|
+
"http://localhost",
|
|
90
|
+
"http://localhost:8788",
|
|
91
|
+
"http://127.0.0.1:3000",
|
|
92
|
+
"http://app.localhost:8788",
|
|
93
|
+
"http://[::1]:8788",
|
|
94
|
+
])("accepts http: for loopback host %s", (url) => {
|
|
95
|
+
expect(loadConfig({ MARQUEE_RESOURCE_URL: url }).resourceUrl).toBe(url);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test.each([
|
|
99
|
+
"http://marquee.example.com",
|
|
100
|
+
"http://192.168.1.10:8788",
|
|
101
|
+
"http://evil.test",
|
|
102
|
+
])("rejects http: for non-loopback host %s", (url) => {
|
|
103
|
+
expect(() => loadConfig({ MARQUEE_RESOURCE_URL: url })).toThrow(
|
|
104
|
+
/must use https: for non-loopback hosts/,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("rejects a non-http(s) scheme", () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
loadConfig({ MARQUEE_RESOURCE_URL: "ftp://files.example.com" }),
|
|
111
|
+
).toThrow(/must use https:/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects a malformed URL", () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
loadConfig({ MARQUEE_RESOURCE_URL: "not-a-url" }),
|
|
117
|
+
).toThrow(/must be a valid absolute URL/);
|
|
118
|
+
});
|
|
119
|
+
});
|