@scenar/cli 0.1.20 → 0.2.1

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 (112) hide show
  1. package/example-bundle/assets/index-BLQ14SEo.js +147 -0
  2. package/example-bundle/assets/logo-f-5LjVqJ.png +0 -0
  3. package/example-bundle/assets/style-Dnzx_jUO.css +1 -0
  4. package/example-bundle/index.html +13 -0
  5. package/example-bundle/pack-manifest.json +36 -0
  6. package/example-bundle/scenario.json +9 -0
  7. package/package.json +17 -5
  8. package/src/__tests__/pack-generate-embed-entry.test.ts +59 -6
  9. package/src/__tests__/publish-command.test.ts +46 -0
  10. package/src/__tests__/publish-flow.test.ts +220 -0
  11. package/src/__tests__/read-viewport.test.ts +45 -0
  12. package/src/__tests__/run-publish.test.ts +92 -0
  13. package/src/__tests__/serve-command.test.ts +44 -0
  14. package/src/__tests__/serve-request-path.test.ts +67 -0
  15. package/src/__tests__/serve-static-server.test.ts +88 -0
  16. package/src/__tests__/try-command.test.ts +21 -0
  17. package/src/api.d.ts +31 -0
  18. package/src/api.d.ts.map +1 -0
  19. package/src/api.js +28 -0
  20. package/src/api.js.map +1 -0
  21. package/src/api.ts +55 -0
  22. package/src/bundle/read-viewport.d.ts +23 -0
  23. package/src/bundle/read-viewport.d.ts.map +1 -0
  24. package/src/bundle/read-viewport.js +25 -0
  25. package/src/bundle/read-viewport.js.map +1 -0
  26. package/src/bundle/read-viewport.ts +36 -0
  27. package/src/commands/deploy.d.ts.map +1 -1
  28. package/src/commands/deploy.js +8 -24
  29. package/src/commands/deploy.js.map +1 -1
  30. package/src/commands/deploy.ts +10 -28
  31. package/src/commands/narrate.d.ts.map +1 -1
  32. package/src/commands/narrate.js +39 -170
  33. package/src/commands/narrate.js.map +1 -1
  34. package/src/commands/narrate.ts +48 -289
  35. package/src/commands/pack.d.ts.map +1 -1
  36. package/src/commands/pack.js +12 -112
  37. package/src/commands/pack.js.map +1 -1
  38. package/src/commands/pack.ts +12 -128
  39. package/src/commands/publish.d.ts +3 -0
  40. package/src/commands/publish.d.ts.map +1 -0
  41. package/src/commands/publish.js +64 -0
  42. package/src/commands/publish.js.map +1 -0
  43. package/src/commands/publish.ts +86 -0
  44. package/src/commands/render.d.ts.map +1 -1
  45. package/src/commands/render.js +12 -194
  46. package/src/commands/render.js.map +1 -1
  47. package/src/commands/render.ts +19 -286
  48. package/src/commands/serve.d.ts +3 -0
  49. package/src/commands/serve.d.ts.map +1 -0
  50. package/src/commands/serve.js +91 -0
  51. package/src/commands/serve.js.map +1 -0
  52. package/src/commands/serve.ts +112 -0
  53. package/src/commands/try.d.ts +3 -0
  54. package/src/commands/try.d.ts.map +1 -0
  55. package/src/commands/try.js +99 -0
  56. package/src/commands/try.js.map +1 -0
  57. package/src/commands/try.ts +122 -0
  58. package/src/index.d.ts.map +1 -1
  59. package/src/index.js +6 -0
  60. package/src/index.js.map +1 -1
  61. package/src/index.ts +6 -0
  62. package/src/narrate/run-narrate.d.ts +43 -0
  63. package/src/narrate/run-narrate.d.ts.map +1 -0
  64. package/src/narrate/run-narrate.js +174 -0
  65. package/src/narrate/run-narrate.js.map +1 -0
  66. package/src/narrate/run-narrate.ts +298 -0
  67. package/src/pack/generate-embed-entry.d.ts +11 -0
  68. package/src/pack/generate-embed-entry.d.ts.map +1 -1
  69. package/src/pack/generate-embed-entry.js +81 -10
  70. package/src/pack/generate-embed-entry.js.map +1 -1
  71. package/src/pack/generate-embed-entry.ts +81 -10
  72. package/src/pack/run-pack.d.ts +34 -0
  73. package/src/pack/run-pack.d.ts.map +1 -0
  74. package/src/pack/run-pack.js +117 -0
  75. package/src/pack/run-pack.js.map +1 -0
  76. package/src/pack/run-pack.ts +163 -0
  77. package/src/publish/github.d.ts +11 -0
  78. package/src/publish/github.d.ts.map +1 -0
  79. package/src/publish/github.js +197 -0
  80. package/src/publish/github.js.map +1 -0
  81. package/src/publish/github.ts +216 -0
  82. package/src/publish/publish-flow.d.ts +110 -0
  83. package/src/publish/publish-flow.d.ts.map +1 -0
  84. package/src/publish/publish-flow.js +117 -0
  85. package/src/publish/publish-flow.js.map +1 -0
  86. package/src/publish/publish-flow.ts +199 -0
  87. package/src/publish/run-publish.d.ts +41 -0
  88. package/src/publish/run-publish.d.ts.map +1 -0
  89. package/src/publish/run-publish.js +77 -0
  90. package/src/publish/run-publish.js.map +1 -0
  91. package/src/publish/run-publish.ts +131 -0
  92. package/src/render/run-render.d.ts +31 -0
  93. package/src/render/run-render.d.ts.map +1 -0
  94. package/src/render/run-render.js +196 -0
  95. package/src/render/run-render.js.map +1 -0
  96. package/src/render/run-render.ts +287 -0
  97. package/src/serve/request-path.d.ts +23 -0
  98. package/src/serve/request-path.d.ts.map +1 -0
  99. package/src/serve/request-path.js +52 -0
  100. package/src/serve/request-path.js.map +1 -0
  101. package/src/serve/request-path.ts +55 -0
  102. package/src/serve/run-serve.d.ts +31 -0
  103. package/src/serve/run-serve.d.ts.map +1 -0
  104. package/src/serve/run-serve.js +35 -0
  105. package/src/serve/run-serve.js.map +1 -0
  106. package/src/serve/run-serve.ts +68 -0
  107. package/src/serve/static-server.d.ts +38 -0
  108. package/src/serve/static-server.d.ts.map +1 -0
  109. package/src/serve/static-server.js +91 -0
  110. package/src/serve/static-server.js.map +1 -0
  111. package/src/serve/static-server.ts +118 -0
  112. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,216 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, mkdir, rm, cp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import type { PublishFlowDeps } from "./publish-flow.js";
6
+
7
+ /** Result of running a child process without throwing on a non-zero exit. */
8
+ interface RunResult {
9
+ readonly code: number;
10
+ readonly stdout: string;
11
+ readonly stderr: string;
12
+ }
13
+
14
+ /** Run a command, capturing output. Never rejects on a non-zero exit code. */
15
+ function run(file: string, args: string[], cwd?: string): Promise<RunResult> {
16
+ return new Promise<RunResult>((resolvePromise) => {
17
+ execFile(file, args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
18
+ const code = error && typeof (error as { code?: unknown }).code === "number"
19
+ ? (error as { code: number }).code
20
+ : error
21
+ ? 1
22
+ : 0;
23
+ resolvePromise({ code, stdout: stdout.toString(), stderr: stderr.toString() });
24
+ });
25
+ });
26
+ }
27
+
28
+ /** Commit identity for the published snapshot (Pages ignores authorship). */
29
+ const COMMIT_USER = "scenar-cli";
30
+ const COMMIT_EMAIL = "scenar-cli@users.noreply.github.com";
31
+
32
+ /**
33
+ * The branch GitHub Pages currently serves for owner/repo, or null if Pages is
34
+ * not enabled (the GET 404s, so `gh` exits non-zero). The authoritative state
35
+ * check that {@link createGithubPublisher}'s enablePages relies on.
36
+ */
37
+ async function getPagesBranch(owner: string, repo: string): Promise<string | null> {
38
+ const result = await run("gh", ["api", `repos/${owner}/${repo}/pages`, "--jq", ".source.branch"]);
39
+ if (result.code !== 0) return null;
40
+ const branch = result.stdout.trim();
41
+ return branch.length > 0 ? branch : null;
42
+ }
43
+
44
+ /**
45
+ * The production {@link PublishFlowDeps} backed by the `gh` CLI and `git`. The
46
+ * `log` sink is supplied by the command; everything here shells out.
47
+ *
48
+ * Auth uses `gh` throughout: API calls go through `gh api`, and the git push
49
+ * borrows gh's credential helper (`gh auth git-credential`) for that one command
50
+ * only — so no token is ever placed on the command line or written to config.
51
+ */
52
+ export function createGithubPublisher(): Omit<PublishFlowDeps, "log"> {
53
+ return {
54
+ async ensureToolsAvailable() {
55
+ const git = await run("git", ["--version"]);
56
+ if (git.code !== 0) {
57
+ throw new Error("git is not installed or not on PATH. Install git and retry.");
58
+ }
59
+ const gh = await run("gh", ["--version"]);
60
+ if (gh.code !== 0) {
61
+ throw new Error(
62
+ "the GitHub CLI (gh) is not installed. Install it from https://cli.github.com and retry.",
63
+ );
64
+ }
65
+ const auth = await run("gh", ["auth", "status"]);
66
+ if (auth.code !== 0) {
67
+ throw new Error("not authenticated with GitHub. Run `gh auth login` and retry.");
68
+ }
69
+ },
70
+
71
+ async resolveOwner(explicitOrg) {
72
+ if (explicitOrg) return explicitOrg;
73
+ const result = await run("gh", ["api", "user", "--jq", ".login"]);
74
+ const login = result.stdout.trim();
75
+ if (result.code !== 0 || login.length === 0) {
76
+ throw new Error(
77
+ `could not resolve your GitHub username via gh (${result.stderr.trim() || "no output"}).`,
78
+ );
79
+ }
80
+ return login;
81
+ },
82
+
83
+ async repoExists(owner, repo) {
84
+ const result = await run("gh", ["repo", "view", `${owner}/${repo}`, "--json", "name"]);
85
+ return result.code === 0;
86
+ },
87
+
88
+ async createRepo(owner, repo, opts) {
89
+ const visibility = opts.private ? "--private" : "--public";
90
+ const result = await run("gh", [
91
+ "repo",
92
+ "create",
93
+ `${owner}/${repo}`,
94
+ visibility,
95
+ "--description",
96
+ opts.description,
97
+ ]);
98
+ if (result.code !== 0) {
99
+ throw new Error(`failed to create ${owner}/${repo}: ${result.stderr.trim() || "unknown error"}`);
100
+ }
101
+ },
102
+
103
+ async pushBundleToPages({ bundleDir, owner, repo, path, message }) {
104
+ const workDir = await mkdtemp(join(tmpdir(), "scenar-publish-"));
105
+ // Borrow gh's credential helper for network git ops only; no token on argv.
106
+ const cred = ["-c", "credential.helper=!gh auth git-credential"];
107
+ const git = async (args: string[]) => {
108
+ const result = await run("git", args, workDir);
109
+ if (result.code !== 0) {
110
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim() || "unknown error"}`);
111
+ }
112
+ return result;
113
+ };
114
+ try {
115
+ // Stage in a throwaway repo so the user's bundle dir is never turned into
116
+ // a git repo.
117
+ const remote = `https://github.com/${owner}/${repo}.git`;
118
+ await git(["init", "-q"]);
119
+ await git(["remote", "add", "origin", remote]);
120
+
121
+ // Subdir publishes build on the existing branch so sibling tours survive;
122
+ // root publishes replace the whole branch (dedicated single-tour repo).
123
+ let basedOnExisting = false;
124
+ if (path) {
125
+ const fetched = await run("git", [...cred, "fetch", "-q", "--depth=1", "origin", "gh-pages"], workDir);
126
+ if (fetched.code === 0) {
127
+ await git(["checkout", "-q", "-B", "gh-pages", "FETCH_HEAD"]);
128
+ basedOnExisting = true;
129
+ } else {
130
+ await git(["checkout", "-q", "-b", "gh-pages"]);
131
+ }
132
+ } else {
133
+ await git(["checkout", "-q", "-B", "gh-pages"]);
134
+ }
135
+
136
+ // Replace only the target subfolder (or the whole tree at root). The
137
+ // root .nojekyll makes Pages serve files verbatim (no Jekyll processing
138
+ // of underscore-prefixed paths, etc.).
139
+ const targetDir = path ? join(workDir, path) : workDir;
140
+ if (path) {
141
+ await rm(targetDir, { recursive: true, force: true });
142
+ await mkdir(dirname(targetDir), { recursive: true });
143
+ }
144
+ await cp(bundleDir, targetDir, { recursive: true });
145
+ await writeFile(join(workDir, ".nojekyll"), "", "utf-8");
146
+
147
+ await git(["add", "-A"]);
148
+
149
+ // Republishing identical content is a no-op — nothing to commit or push.
150
+ const status = await git(["status", "--porcelain"]);
151
+ if (status.stdout.trim().length === 0) return;
152
+
153
+ await git([
154
+ "-c", `user.name=${COMMIT_USER}`,
155
+ "-c", `user.email=${COMMIT_EMAIL}`,
156
+ "commit", "-q", "-m", message,
157
+ ]);
158
+
159
+ // Fast-forward push when built on the fetched head; otherwise force
160
+ // (creating or replacing the branch wholesale).
161
+ const pushArgs = basedOnExisting
162
+ ? [...cred, "push", "-q", "origin", "gh-pages"]
163
+ : [...cred, "push", "-q", "-f", "origin", "gh-pages"];
164
+ const pushed = await run("git", pushArgs, workDir);
165
+ if (pushed.code !== 0) {
166
+ throw new Error(`git push failed: ${pushed.stderr.trim() || "unknown error"}`);
167
+ }
168
+ } finally {
169
+ await rm(workDir, { recursive: true, force: true }).catch(() => {});
170
+ }
171
+ },
172
+
173
+ async enablePages(owner, repo) {
174
+ // Verify state rather than trust POST's exit code. The Pages create
175
+ // endpoint can return an async 202 with an empty body, which makes
176
+ // `gh api` exit non-zero ("unexpected end of JSON input") even though the
177
+ // site was enabled. GET is the authoritative check and is naturally
178
+ // idempotent for re-publishes.
179
+ const current = await getPagesBranch(owner, repo);
180
+ if (current === "gh-pages") return; // already pointed at our branch
181
+ if (current !== null) {
182
+ // Enabled, but from a different branch — repoint it.
183
+ const put = await run("gh", [
184
+ "api",
185
+ "-X",
186
+ "PUT",
187
+ `repos/${owner}/${repo}/pages`,
188
+ "-f",
189
+ "source[branch]=gh-pages",
190
+ "-f",
191
+ "source[path]=/",
192
+ ]);
193
+ if (put.code !== 0) {
194
+ throw new Error(`failed to repoint GitHub Pages to gh-pages: ${put.stderr.trim() || "unknown error"}`);
195
+ }
196
+ return;
197
+ }
198
+
199
+ // Not enabled yet — create it, then confirm via GET (ignore the create's
200
+ // noisy exit/parse behaviour and rely on the observed state).
201
+ const post = await run("gh", [
202
+ "api",
203
+ "-X",
204
+ "POST",
205
+ `repos/${owner}/${repo}/pages`,
206
+ "-f",
207
+ "source[branch]=gh-pages",
208
+ "-f",
209
+ "source[path]=/",
210
+ ]);
211
+ const after = await getPagesBranch(owner, repo);
212
+ if (after !== null) return;
213
+ throw new Error(`failed to enable GitHub Pages: ${post.stderr.trim() || "unknown error"}`);
214
+ },
215
+ };
216
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * GitHub Pages publishing — the OSS "permanent public URL" path of Phase 1.
3
+ *
4
+ * The flow is dependency-injected (mirroring deploy-flow.ts) so its ordering and
5
+ * payloads are unit-testable against fakes, with zero live GitHub calls in the
6
+ * test suite. The real `gh`/`git` implementations live in ./github.ts.
7
+ */
8
+ /** Identity for the target GitHub repository + how to write to it. */
9
+ export interface PublishOptions {
10
+ /** The packed bundle directory to publish. */
11
+ readonly bundleDir: string;
12
+ /** Target repository name (default: the scenario slug). */
13
+ readonly repo: string;
14
+ /**
15
+ * Subdirectory within the repo to publish into, normalized (no leading/
16
+ * trailing slashes). Empty string means the repo root — a dedicated,
17
+ * single-tour repo whose every publish replaces the whole branch. A non-empty
18
+ * path (e.g. `welcome-tour`) lets many tours coexist in one repo; publishing
19
+ * replaces only that subfolder and preserves its siblings.
20
+ */
21
+ readonly path: string;
22
+ /** Target owner (a GitHub org); omitted means the authenticated user. */
23
+ readonly org?: string;
24
+ /** Create the repo private rather than public (Pages needs Pro for private). */
25
+ readonly private: boolean;
26
+ /** Commit message for the published snapshot. */
27
+ readonly message: string;
28
+ }
29
+ /** The published location. */
30
+ export interface PublishResult {
31
+ readonly owner: string;
32
+ readonly repo: string;
33
+ /** The subdirectory the embed was published into ("" for the repo root). */
34
+ readonly path: string;
35
+ /** The repository's web URL. */
36
+ readonly repoUrl: string;
37
+ /** The GitHub Pages URL the embed is served at (includes the path). */
38
+ readonly pagesUrl: string;
39
+ /** True when the repo was created by this run (vs. reused). */
40
+ readonly created: boolean;
41
+ }
42
+ /**
43
+ * The side-effecting operations the flow depends on, injected so the ordering
44
+ * can be tested without `gh`, `git`, or the network.
45
+ */
46
+ export interface PublishFlowDeps {
47
+ /** Verify `gh` + `git` are installed and `gh` is authenticated; else throw. */
48
+ ensureToolsAvailable(): Promise<void>;
49
+ /** Resolve the target owner: the explicit org, or the authenticated user. */
50
+ resolveOwner(explicitOrg: string | undefined): Promise<string>;
51
+ /** Whether owner/repo already exists. */
52
+ repoExists(owner: string, repo: string): Promise<boolean>;
53
+ /** Create owner/repo (public unless private requested). */
54
+ createRepo(owner: string, repo: string, opts: {
55
+ private: boolean;
56
+ description: string;
57
+ }): Promise<void>;
58
+ /**
59
+ * Publish the bundle (plus a root .nojekyll) to the repo's gh-pages branch.
60
+ * An empty `path` replaces the whole branch (dedicated repo); a non-empty
61
+ * `path` updates only that subfolder, preserving sibling tours already on the
62
+ * branch.
63
+ */
64
+ pushBundleToPages(input: {
65
+ bundleDir: string;
66
+ owner: string;
67
+ repo: string;
68
+ path: string;
69
+ message: string;
70
+ }): Promise<void>;
71
+ /** Enable GitHub Pages from gh-pages (idempotent — a no-op if already on). */
72
+ enablePages(owner: string, repo: string): Promise<void>;
73
+ /** Progress sink (stderr in the command; captured in tests). */
74
+ log(message: string): void;
75
+ }
76
+ /**
77
+ * Validate a GitHub repository name. Returns an error message, or null if valid.
78
+ * The scenario slug (the default) is always valid; this guards an explicit
79
+ * `--repo` before any repo is touched.
80
+ */
81
+ export declare function validateRepoName(repo: string): string | null;
82
+ /**
83
+ * Normalize a publish path to a clean, slash-separated subpath with no leading,
84
+ * trailing, or doubled slashes. `/`, `.`, and empty all normalize to "" (root).
85
+ */
86
+ export declare function normalizePath(path: string | undefined): string;
87
+ /**
88
+ * Validate a (pre-normalized) publish subpath. Returns an error message, or null
89
+ * if valid. "" (root) is always valid; each segment must be a safe slug and
90
+ * never `..` (no directory traversal into the branch).
91
+ */
92
+ export declare function validatePublishPath(path: string): string | null;
93
+ /**
94
+ * The GitHub Pages URL for owner/repo, optionally under a subpath. A repository
95
+ * named exactly `<owner>.github.io` is the user/org site and serves at the apex;
96
+ * every other repo serves under its name as a path (which is why packed bundles
97
+ * use a relative base so they work from a subpath). A non-empty `path` appends a
98
+ * further subdirectory so many tours can share one repo.
99
+ */
100
+ export declare function pagesUrl(owner: string, repo: string, path?: string): string;
101
+ /** The repository's web URL. */
102
+ export declare function repoUrl(owner: string, repo: string): string;
103
+ /**
104
+ * Drive the publish: verify tooling, resolve the owner, ensure the repo exists,
105
+ * push the bundle to gh-pages, and enable Pages. Ordering is load-bearing — the
106
+ * repo must exist before the push, and the gh-pages branch must exist (from the
107
+ * push) before Pages can be pointed at it.
108
+ */
109
+ export declare function runPublishFlow(deps: PublishFlowDeps, opts: PublishOptions): Promise<PublishResult>;
110
+ //# sourceMappingURL=publish-flow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish-flow.d.ts","sourceRoot":"","sources":["../../../src/publish/publish-flow.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAWH,sEAAsE;AACtE,MAAM,WAAW,cAAc;IAC7B,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,2DAA2D;IAC3D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,gFAAgF;IAChF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,8BAA8B;AAC9B,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,gCAAgC;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,6EAA6E;IAC7E,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/D,yCAAyC;IACzC,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,2DAA2D;IAC3D,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxG;;;;;OAKG;IACH,iBAAiB,CAAC,KAAK,EAAE;QACvB,SAAS,EAAE,MAAM,CAAC;QAClB,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClB,8EAA8E;IAC9E,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,gEAAgE;IAChE,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAW5D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAM9D;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAS/D;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAK3E;AAED,gCAAgC;AAChC,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,aAAa,CAAC,CAuCxB"}
@@ -0,0 +1,117 @@
1
+ /**
2
+ * GitHub Pages publishing — the OSS "permanent public URL" path of Phase 1.
3
+ *
4
+ * The flow is dependency-injected (mirroring deploy-flow.ts) so its ordering and
5
+ * payloads are unit-testable against fakes, with zero live GitHub calls in the
6
+ * test suite. The real `gh`/`git` implementations live in ./github.ts.
7
+ */
8
+ /** A repo name's allowed characters (GitHub: alphanumerics, ., _, -). */
9
+ const REPO_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
10
+ /** GitHub caps repository names at 100 characters. */
11
+ const MAX_REPO_NAME_LENGTH = 100;
12
+ /** Allowed characters for a single publish path segment. */
13
+ const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
14
+ /**
15
+ * Validate a GitHub repository name. Returns an error message, or null if valid.
16
+ * The scenario slug (the default) is always valid; this guards an explicit
17
+ * `--repo` before any repo is touched.
18
+ */
19
+ export function validateRepoName(repo) {
20
+ if (repo.length === 0 || repo.length > MAX_REPO_NAME_LENGTH) {
21
+ return `repo name must be 1-${MAX_REPO_NAME_LENGTH} characters`;
22
+ }
23
+ if (repo === "." || repo === "..") {
24
+ return `repo name must not be "." or ".."`;
25
+ }
26
+ if (!REPO_NAME_PATTERN.test(repo)) {
27
+ return `invalid repo name "${repo}"; allowed: letters, digits, ".", "_", "-"`;
28
+ }
29
+ return null;
30
+ }
31
+ /**
32
+ * Normalize a publish path to a clean, slash-separated subpath with no leading,
33
+ * trailing, or doubled slashes. `/`, `.`, and empty all normalize to "" (root).
34
+ */
35
+ export function normalizePath(path) {
36
+ return (path ?? "")
37
+ .split("/")
38
+ .map((seg) => seg.trim())
39
+ .filter((seg) => seg.length > 0 && seg !== ".")
40
+ .join("/");
41
+ }
42
+ /**
43
+ * Validate a (pre-normalized) publish subpath. Returns an error message, or null
44
+ * if valid. "" (root) is always valid; each segment must be a safe slug and
45
+ * never `..` (no directory traversal into the branch).
46
+ */
47
+ export function validatePublishPath(path) {
48
+ if (path.length === 0)
49
+ return null;
50
+ for (const seg of path.split("/")) {
51
+ if (seg === "..")
52
+ return `path must not contain ".."`;
53
+ if (!PATH_SEGMENT_PATTERN.test(seg)) {
54
+ return `invalid path segment "${seg}"; allowed: letters, digits, ".", "_", "-"`;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * The GitHub Pages URL for owner/repo, optionally under a subpath. A repository
61
+ * named exactly `<owner>.github.io` is the user/org site and serves at the apex;
62
+ * every other repo serves under its name as a path (which is why packed bundles
63
+ * use a relative base so they work from a subpath). A non-empty `path` appends a
64
+ * further subdirectory so many tours can share one repo.
65
+ */
66
+ export function pagesUrl(owner, repo, path) {
67
+ const host = `${owner.toLowerCase()}.github.io`;
68
+ const base = repo.toLowerCase() === host ? `https://${host}/` : `https://${host}/${repo}/`;
69
+ const clean = normalizePath(path);
70
+ return clean ? `${base}${clean}/` : base;
71
+ }
72
+ /** The repository's web URL. */
73
+ export function repoUrl(owner, repo) {
74
+ return `https://github.com/${owner}/${repo}`;
75
+ }
76
+ /**
77
+ * Drive the publish: verify tooling, resolve the owner, ensure the repo exists,
78
+ * push the bundle to gh-pages, and enable Pages. Ordering is load-bearing — the
79
+ * repo must exist before the push, and the gh-pages branch must exist (from the
80
+ * push) before Pages can be pointed at it.
81
+ */
82
+ export async function runPublishFlow(deps, opts) {
83
+ await deps.ensureToolsAvailable();
84
+ const owner = await deps.resolveOwner(opts.org);
85
+ const exists = await deps.repoExists(owner, opts.repo);
86
+ if (!exists) {
87
+ deps.log(`Creating ${opts.private ? "private" : "public"} repo ${owner}/${opts.repo}...`);
88
+ await deps.createRepo(owner, opts.repo, {
89
+ private: opts.private,
90
+ description: "Scenar embed — published with `scenar publish`.",
91
+ });
92
+ }
93
+ else {
94
+ deps.log(`Reusing existing repo ${owner}/${opts.repo}...`);
95
+ }
96
+ deps.log(opts.path
97
+ ? `Publishing into ${opts.repo}/${opts.path}/ on the gh-pages branch...`
98
+ : "Pushing bundle to the gh-pages branch...");
99
+ await deps.pushBundleToPages({
100
+ bundleDir: opts.bundleDir,
101
+ owner,
102
+ repo: opts.repo,
103
+ path: opts.path,
104
+ message: opts.message,
105
+ });
106
+ deps.log("Enabling GitHub Pages...");
107
+ await deps.enablePages(owner, opts.repo);
108
+ return {
109
+ owner,
110
+ repo: opts.repo,
111
+ path: opts.path,
112
+ repoUrl: repoUrl(owner, opts.repo),
113
+ pagesUrl: pagesUrl(owner, opts.repo, opts.path),
114
+ created: !exists,
115
+ };
116
+ }
117
+ //# sourceMappingURL=publish-flow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish-flow.js","sourceRoot":"","sources":["../../../src/publish/publish-flow.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,199 @@
1
+ /**
2
+ * GitHub Pages publishing — the OSS "permanent public URL" path of Phase 1.
3
+ *
4
+ * The flow is dependency-injected (mirroring deploy-flow.ts) so its ordering and
5
+ * payloads are unit-testable against fakes, with zero live GitHub calls in the
6
+ * test suite. The real `gh`/`git` implementations live in ./github.ts.
7
+ */
8
+
9
+ /** A repo name's allowed characters (GitHub: alphanumerics, ., _, -). */
10
+ const REPO_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
11
+
12
+ /** GitHub caps repository names at 100 characters. */
13
+ const MAX_REPO_NAME_LENGTH = 100;
14
+
15
+ /** Allowed characters for a single publish path segment. */
16
+ const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
17
+
18
+ /** Identity for the target GitHub repository + how to write to it. */
19
+ export interface PublishOptions {
20
+ /** The packed bundle directory to publish. */
21
+ readonly bundleDir: string;
22
+ /** Target repository name (default: the scenario slug). */
23
+ readonly repo: string;
24
+ /**
25
+ * Subdirectory within the repo to publish into, normalized (no leading/
26
+ * trailing slashes). Empty string means the repo root — a dedicated,
27
+ * single-tour repo whose every publish replaces the whole branch. A non-empty
28
+ * path (e.g. `welcome-tour`) lets many tours coexist in one repo; publishing
29
+ * replaces only that subfolder and preserves its siblings.
30
+ */
31
+ readonly path: string;
32
+ /** Target owner (a GitHub org); omitted means the authenticated user. */
33
+ readonly org?: string;
34
+ /** Create the repo private rather than public (Pages needs Pro for private). */
35
+ readonly private: boolean;
36
+ /** Commit message for the published snapshot. */
37
+ readonly message: string;
38
+ }
39
+
40
+ /** The published location. */
41
+ export interface PublishResult {
42
+ readonly owner: string;
43
+ readonly repo: string;
44
+ /** The subdirectory the embed was published into ("" for the repo root). */
45
+ readonly path: string;
46
+ /** The repository's web URL. */
47
+ readonly repoUrl: string;
48
+ /** The GitHub Pages URL the embed is served at (includes the path). */
49
+ readonly pagesUrl: string;
50
+ /** True when the repo was created by this run (vs. reused). */
51
+ readonly created: boolean;
52
+ }
53
+
54
+ /**
55
+ * The side-effecting operations the flow depends on, injected so the ordering
56
+ * can be tested without `gh`, `git`, or the network.
57
+ */
58
+ export interface PublishFlowDeps {
59
+ /** Verify `gh` + `git` are installed and `gh` is authenticated; else throw. */
60
+ ensureToolsAvailable(): Promise<void>;
61
+ /** Resolve the target owner: the explicit org, or the authenticated user. */
62
+ resolveOwner(explicitOrg: string | undefined): Promise<string>;
63
+ /** Whether owner/repo already exists. */
64
+ repoExists(owner: string, repo: string): Promise<boolean>;
65
+ /** Create owner/repo (public unless private requested). */
66
+ createRepo(owner: string, repo: string, opts: { private: boolean; description: string }): Promise<void>;
67
+ /**
68
+ * Publish the bundle (plus a root .nojekyll) to the repo's gh-pages branch.
69
+ * An empty `path` replaces the whole branch (dedicated repo); a non-empty
70
+ * `path` updates only that subfolder, preserving sibling tours already on the
71
+ * branch.
72
+ */
73
+ pushBundleToPages(input: {
74
+ bundleDir: string;
75
+ owner: string;
76
+ repo: string;
77
+ path: string;
78
+ message: string;
79
+ }): Promise<void>;
80
+ /** Enable GitHub Pages from gh-pages (idempotent — a no-op if already on). */
81
+ enablePages(owner: string, repo: string): Promise<void>;
82
+ /** Progress sink (stderr in the command; captured in tests). */
83
+ log(message: string): void;
84
+ }
85
+
86
+ /**
87
+ * Validate a GitHub repository name. Returns an error message, or null if valid.
88
+ * The scenario slug (the default) is always valid; this guards an explicit
89
+ * `--repo` before any repo is touched.
90
+ */
91
+ export function validateRepoName(repo: string): string | null {
92
+ if (repo.length === 0 || repo.length > MAX_REPO_NAME_LENGTH) {
93
+ return `repo name must be 1-${MAX_REPO_NAME_LENGTH} characters`;
94
+ }
95
+ if (repo === "." || repo === "..") {
96
+ return `repo name must not be "." or ".."`;
97
+ }
98
+ if (!REPO_NAME_PATTERN.test(repo)) {
99
+ return `invalid repo name "${repo}"; allowed: letters, digits, ".", "_", "-"`;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Normalize a publish path to a clean, slash-separated subpath with no leading,
106
+ * trailing, or doubled slashes. `/`, `.`, and empty all normalize to "" (root).
107
+ */
108
+ export function normalizePath(path: string | undefined): string {
109
+ return (path ?? "")
110
+ .split("/")
111
+ .map((seg) => seg.trim())
112
+ .filter((seg) => seg.length > 0 && seg !== ".")
113
+ .join("/");
114
+ }
115
+
116
+ /**
117
+ * Validate a (pre-normalized) publish subpath. Returns an error message, or null
118
+ * if valid. "" (root) is always valid; each segment must be a safe slug and
119
+ * never `..` (no directory traversal into the branch).
120
+ */
121
+ export function validatePublishPath(path: string): string | null {
122
+ if (path.length === 0) return null;
123
+ for (const seg of path.split("/")) {
124
+ if (seg === "..") return `path must not contain ".."`;
125
+ if (!PATH_SEGMENT_PATTERN.test(seg)) {
126
+ return `invalid path segment "${seg}"; allowed: letters, digits, ".", "_", "-"`;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * The GitHub Pages URL for owner/repo, optionally under a subpath. A repository
134
+ * named exactly `<owner>.github.io` is the user/org site and serves at the apex;
135
+ * every other repo serves under its name as a path (which is why packed bundles
136
+ * use a relative base so they work from a subpath). A non-empty `path` appends a
137
+ * further subdirectory so many tours can share one repo.
138
+ */
139
+ export function pagesUrl(owner: string, repo: string, path?: string): string {
140
+ const host = `${owner.toLowerCase()}.github.io`;
141
+ const base = repo.toLowerCase() === host ? `https://${host}/` : `https://${host}/${repo}/`;
142
+ const clean = normalizePath(path);
143
+ return clean ? `${base}${clean}/` : base;
144
+ }
145
+
146
+ /** The repository's web URL. */
147
+ export function repoUrl(owner: string, repo: string): string {
148
+ return `https://github.com/${owner}/${repo}`;
149
+ }
150
+
151
+ /**
152
+ * Drive the publish: verify tooling, resolve the owner, ensure the repo exists,
153
+ * push the bundle to gh-pages, and enable Pages. Ordering is load-bearing — the
154
+ * repo must exist before the push, and the gh-pages branch must exist (from the
155
+ * push) before Pages can be pointed at it.
156
+ */
157
+ export async function runPublishFlow(
158
+ deps: PublishFlowDeps,
159
+ opts: PublishOptions,
160
+ ): Promise<PublishResult> {
161
+ await deps.ensureToolsAvailable();
162
+
163
+ const owner = await deps.resolveOwner(opts.org);
164
+ const exists = await deps.repoExists(owner, opts.repo);
165
+ if (!exists) {
166
+ deps.log(`Creating ${opts.private ? "private" : "public"} repo ${owner}/${opts.repo}...`);
167
+ await deps.createRepo(owner, opts.repo, {
168
+ private: opts.private,
169
+ description: "Scenar embed — published with `scenar publish`.",
170
+ });
171
+ } else {
172
+ deps.log(`Reusing existing repo ${owner}/${opts.repo}...`);
173
+ }
174
+
175
+ deps.log(
176
+ opts.path
177
+ ? `Publishing into ${opts.repo}/${opts.path}/ on the gh-pages branch...`
178
+ : "Pushing bundle to the gh-pages branch...",
179
+ );
180
+ await deps.pushBundleToPages({
181
+ bundleDir: opts.bundleDir,
182
+ owner,
183
+ repo: opts.repo,
184
+ path: opts.path,
185
+ message: opts.message,
186
+ });
187
+
188
+ deps.log("Enabling GitHub Pages...");
189
+ await deps.enablePages(owner, opts.repo);
190
+
191
+ return {
192
+ owner,
193
+ repo: opts.repo,
194
+ path: opts.path,
195
+ repoUrl: repoUrl(owner, opts.repo),
196
+ pagesUrl: pagesUrl(owner, opts.repo, opts.path),
197
+ created: !exists,
198
+ };
199
+ }