@rathnasgala/cli 0.0.18 → 0.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/cli",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -27,7 +27,10 @@ export async function generateRepositoryFromTemplate({
27
27
  owner,
28
28
  repository,
29
29
  description,
30
- fetchImpl = fetch
30
+ fetchImpl = fetch,
31
+ sleep,
32
+ readinessAttempts,
33
+ readinessIntervalMs
31
34
  }) {
32
35
  const token = requiredString(accessToken, 'accessToken');
33
36
  const sourceOwner = repositorySegment(templateOwner, 'templateOwner');
@@ -85,9 +88,53 @@ export async function generateRepositoryFromTemplate({
85
88
  throw new TypeError('GitHub repository response contains an invalid clone_url');
86
89
  }
87
90
 
91
+ // Last, so a malformed response fails immediately instead of after the readiness wait.
92
+ await awaitRepositoryContent({
93
+ accessToken: token, owner: targetOwner, repository: targetRepository, fetchImpl,
94
+ ...(sleep == null ? {} : { sleep }),
95
+ ...(readinessAttempts == null ? {} : { attempts: readinessAttempts }),
96
+ ...(readinessIntervalMs == null ? {} : { intervalMs: readinessIntervalMs })
97
+ });
98
+
88
99
  return Object.freeze({ fullName, cloneUrl: cloneUrl.href });
89
100
  }
90
101
 
102
+ /**
103
+ * Waits for a generated repository to actually contain the template.
104
+ *
105
+ * Generating from a template is asynchronous: GitHub answers 201 with the repository's full name
106
+ * and clone URL, and copies the content in afterwards. Cloning on the 201 produced
107
+ * "warning: You appear to have cloned an empty repository", and scaffolding then failed on a
108
+ * missing site.config.yml — a confusing error about a file the template certainly contains.
109
+ *
110
+ * Readiness is the presence of a branch. `size` is not usable: GitHub still reported 0 for a
111
+ * repository that already had `main` and commits.
112
+ */
113
+ export async function awaitRepositoryContent({
114
+ accessToken, owner, repository, fetchImpl = fetch,
115
+ sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
116
+ attempts = 30, intervalMs = 1_000
117
+ }) {
118
+ const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?per_page=1`;
119
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
120
+ if (attempt > 0) await sleep(intervalMs);
121
+ const response = await fetchImpl(url, {
122
+ headers: {
123
+ accept: 'application/vnd.github+json',
124
+ authorization: `Bearer ${accessToken}`,
125
+ 'x-github-api-version': GITHUB_API_VERSION
126
+ }
127
+ });
128
+ if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub branch lookup'));
129
+ const branches = await response.json();
130
+ if (Array.isArray(branches) && branches.length > 0) return;
131
+ }
132
+ throw new Error(
133
+ `GitHub created ${owner}/${repository} from the template but it was still empty after `
134
+ + `${Math.round((attempts * intervalMs) / 1000)}s. Re-run scaffold with --resume once it has content.`
135
+ );
136
+ }
137
+
91
138
  export function cloneRepository({ cloneUrl, target, spawnProcess = spawn }) {
92
139
  const source = new URL(requiredString(cloneUrl, 'cloneUrl'));
93
140
  if (
package/src/index.js CHANGED
@@ -130,6 +130,9 @@ if (command === 'auth') {
130
130
  ask
131
131
  });
132
132
  const result = await scaffoldSite({
133
+ notify: (message) => process.stdout.write(`${message}\n`),
134
+ ask,
135
+ openUrl: openInBrowser,
133
136
  owner: prepared.owner,
134
137
  repository: prepared.repository,
135
138
  target: prepared.target,
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Creates the publication repository the way the browser editor does.
3
+ *
4
+ * The CLI used to call `POST /repos/{template}/generate` itself. That is a second, worse
5
+ * implementation of something the API already does and does properly:
6
+ *
7
+ * - it tries the template, then falls back to creating an empty repository and seeding it, then
8
+ * reports that the writer must do it by hand — the CLI's single attempt had no rung below it;
9
+ * - it waits for the Gala App installation to actually reach the new repository before calling it
10
+ * ready, which is why repositories the CLI created never appeared in the web UI;
11
+ * - it returns the installation id, so registration cannot disagree with creation about which
12
+ * installation owns the repository.
13
+ *
14
+ * One implementation, exercised by both clients, is the only way the two stay in step.
15
+ */
16
+ import { describeHttpFailure } from './http-failure.js';
17
+ import { exchangeGithubAuthorization } from './site-registration-client.js';
18
+
19
+ export async function createPublication({
20
+ apiBaseUrl = 'https://api.gala67.com',
21
+ galaAccessToken,
22
+ githubAccessToken,
23
+ name,
24
+ fetchImpl = fetch,
25
+ authorize = exchangeGithubAuthorization,
26
+ notify = () => {},
27
+ ask,
28
+ openUrl = () => false,
29
+ shareAttempts = 3,
30
+ installationsUrl = 'https://github.com/settings/installations'
31
+ }) {
32
+ let created = false;
33
+ for (let attempt = 0; attempt < Math.max(1, shareAttempts); attempt += 1) {
34
+ const result = await requestPublication({
35
+ apiBaseUrl, galaAccessToken, githubAccessToken, name, fetchImpl, authorize
36
+ });
37
+ if (result.ready) return result.publication;
38
+
39
+ /*
40
+ * The repository exists with the right content; the App installation simply cannot see it,
41
+ * because it is scoped to selected repositories rather than all of them. Sharing it is a click,
42
+ * and asking again then returns READY — the server short-circuits on a repository it can
43
+ * already see. The browser editor recovers the same way; without this the CLI dead-ends on a
44
+ * state that is one click from working.
45
+ *
46
+ * After the first attempt the repository exists, so a further refusal reports UNSUPPORTED
47
+ * rather than NEEDS_SHARING — the same situation under a different name.
48
+ */
49
+ const shareable = result.status === 'NEEDS_SHARING' || created;
50
+ created = created || result.status === 'NEEDS_SHARING';
51
+ if (!shareable || typeof ask !== 'function') throw result.failure;
52
+
53
+ notify(`${result.owner ?? ''}/${result.repository ?? name} exists, but the Gala GitHub App `
54
+ + 'cannot reach it yet — its installation covers only selected repositories.');
55
+ notify(`${openUrl(installationsUrl) ? 'Opened' : 'Open'} ${installationsUrl}`);
56
+ await ask('Press enter once the App can access that repository. ');
57
+ }
58
+ throw new Error(
59
+ `The Gala GitHub App still cannot reach the repository for ${name}. Give it access at `
60
+ + `${installationsUrl}, then run scaffold again.`
61
+ );
62
+ }
63
+
64
+ async function requestPublication({
65
+ apiBaseUrl, galaAccessToken, githubAccessToken, name, fetchImpl, authorize
66
+ }) {
67
+ const authorization = await authorize({
68
+ apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
69
+ });
70
+ const response = await fetchImpl(`${String(apiBaseUrl).replace(/\/$/, '')}/v1/auth/github/publications`, {
71
+ method: 'POST',
72
+ headers: {
73
+ accept: 'application/json',
74
+ authorization: `Bearer ${galaAccessToken}`,
75
+ 'content-type': 'application/json',
76
+ 'GitHub-Authorization': authorization
77
+ },
78
+ body: JSON.stringify({ name })
79
+ });
80
+ if (!response.ok) throw new Error(await describeHttpFailure(response, 'Gala publication creation'));
81
+
82
+ const payload = await response.json();
83
+ const status = payload?.status;
84
+ const owner = payload?.owner;
85
+ const repository = payload?.name;
86
+
87
+ if (status !== 'READY') {
88
+ return {
89
+ ready: false, status, owner, repository,
90
+ failure: new Error(
91
+ `Gala could not create the publication repository (${payload?.outcome ?? status}). `
92
+ + 'Give the Gala GitHub App access to it at https://github.com/settings/installations, or '
93
+ + 'create it from https://github.com/rathnasgala/site-template yourself and run scaffold '
94
+ + 'with --empty-existing-repository.'
95
+ )
96
+ };
97
+ }
98
+ if (typeof owner !== 'string' || typeof repository !== 'string') {
99
+ throw new TypeError('Gala publication creation returned no repository identity');
100
+ }
101
+ const installationId = Number(payload?.installationId);
102
+ if (!Number.isSafeInteger(installationId) || installationId <= 0) {
103
+ throw new TypeError('Gala publication creation returned no installation');
104
+ }
105
+
106
+ return { ready: true, status, owner, repository, publication: Object.freeze({
107
+ owner,
108
+ repository,
109
+ installationId,
110
+ outcome: payload?.outcome ?? null,
111
+ // The server names the repository it actually made, which may differ from what was asked for.
112
+ fullName: `${owner}/${repository}`,
113
+ cloneUrl: `https://github.com/${owner}/${repository}.git`
114
+ }) };
115
+ }
@@ -4,7 +4,8 @@ import path from 'node:path';
4
4
  import { configureSite } from './configure-site.js';
5
5
  import { readGalaCredential } from './gala-credential-store.js';
6
6
  import { readGithubCredential } from './github-credential-store.js';
7
- import { cloneRepository, generateRepositoryFromTemplate } from './github-template-repository.js';
7
+ import { awaitRepositoryContent, cloneRepository } from './github-template-repository.js';
8
+ import { createPublication } from './publication-creation-client.js';
8
9
  import { installRepositoryVariable } from './github-repository-variable.js';
9
10
  import { provisionGithubPages } from './github-pages-provisioning.js';
10
11
  import { registerSite } from './site-registration-client.js';
@@ -47,11 +48,12 @@ function registrationLocation(owner, topology, canonicalBaseUrl) {
47
48
 
48
49
  export async function scaffoldSite({
49
50
  owner, repository, target, githubInstallationId, siteOptions, emptyExistingRepository = false,
51
+ notify = (message) => process.stdout.write(`${message}\n`), ask, openUrl,
50
52
  resumeExistingCheckout = false, topology = 'provider-default', canonicalBaseUrl, actionRef,
51
53
  buildMode = 'build-and-deploy', templateOwner = 'rathnasgala',
52
54
  templateRepository = 'site-template',
53
55
  readGithub = readGithubCredential, readGala = readGalaCredential,
54
- generate = generateRepositoryFromTemplate, clone = cloneRepository,
56
+ createRepository = createPublication, awaitContent = awaitRepositoryContent, clone = cloneRepository,
55
57
  configure = configureSite, register = registerSite, finalize = writeRegisteredSiteConfiguration,
56
58
  writeWorkflow = writePublishWorkflow,
57
59
  installVariable = installRepositoryVariable,
@@ -59,9 +61,11 @@ export async function scaffoldSite({
59
61
  commit = commitScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
60
62
  verifyCheckout = verifyRepositoryOrigin
61
63
  }) {
62
- const repositoryOwner = segment(owner, 'owner');
63
- const repositoryName = segment(repository, 'repository');
64
- const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
64
+ const requestedOwner = segment(owner, 'owner');
65
+ const requestedName = segment(repository, 'repository');
66
+ // Validated now so a bad --topology/--canonical-base-url combination fails before a repository
67
+ // exists. The value used later is recomputed once the server says who actually owns it.
68
+ registrationLocation(requestedOwner, topology, canonicalBaseUrl);
65
69
  // Optional: the server resolves the installation from the owner when none is supplied. An
66
70
  // explicit value is still validated, because a wrong one fails much later and less clearly.
67
71
  if (githubInstallationId != null
@@ -72,9 +76,20 @@ export async function scaffoldSite({
72
76
  throw new TypeError('target must be a non-root local path');
73
77
  }
74
78
  const [github, gala] = await Promise.all([readGithub(), readGala()]);
79
+ // Creation reports which installation owns the new repository, so registration cannot disagree.
80
+ let resolvedInstallationId = githubInstallationId ?? null;
75
81
  if (emptyExistingRepository && resumeExistingCheckout) {
76
82
  throw new TypeError('emptyExistingRepository and resumeExistingCheckout are mutually exclusive');
77
83
  }
84
+ /*
85
+ * The server decides the owner, not this process. It creates under the account the Gala App
86
+ * installation belongs to, which is not always the account behind the writer's OAuth token — an
87
+ * installation on an organisation they belong to gives a different owner entirely. Deriving the
88
+ * canonical URL, the idempotency key, the registration and the Pages target from the local guess
89
+ * would register a publication against a repository that does not exist.
90
+ */
91
+ let repositoryOwner = requestedOwner;
92
+ let repositoryName = requestedName;
78
93
  let generated;
79
94
  let root;
80
95
  if (resumeExistingCheckout) {
@@ -89,22 +104,37 @@ export async function scaffoldSite({
89
104
  cloneUrl: `https://github.com/${templateOwner}/${templateRepository}.git`
90
105
  };
91
106
  } else {
92
- generated = await generate({
93
- accessToken: github.accessToken, templateOwner, templateRepository,
94
- owner: repositoryOwner, repository: repositoryName,
95
- description: siteOptions?.siteName ?? ''
107
+ /*
108
+ * Created through the API, the same call the browser editor makes. Doing it here meant a second
109
+ * implementation with no fallback and no wait for the App installation to reach the result,
110
+ * which is why CLI-created repositories never appeared in the web UI.
111
+ */
112
+ const created = await createRepository({
113
+ apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
114
+ githubAccessToken: github.accessToken, name: repositoryName,
115
+ notify, ask, openUrl
116
+ });
117
+ repositoryOwner = segment(created.owner, 'owner');
118
+ repositoryName = segment(created.repository, 'repository');
119
+ resolvedInstallationId = created.installationId;
120
+ generated = { fullName: created.fullName, cloneUrl: created.cloneUrl };
121
+ // Creation is asynchronous: GitHub answers before the template content lands, and cloning into
122
+ // that window produces an empty checkout and a missing site.config.yml.
123
+ await awaitContent({
124
+ accessToken: github.accessToken, owner: created.owner, repository: created.repository
96
125
  });
97
126
  }
98
127
  if (!resumeExistingCheckout) {
99
128
  root = await clone({ cloneUrl: generated.cloneUrl, target });
100
129
  if (emptyExistingRepository) await setOrigin({ root, owner: repositoryOwner, repository: repositoryName });
101
130
  }
131
+ const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
102
132
  const configured = await configure(root, siteOptions ?? {});
103
133
  const idempotencyKey = `scaffold-${createHash('sha256').update(`${repositoryOwner.toLowerCase()}/${repositoryName.toLowerCase()}`).digest('hex')}`;
104
134
  const registration = await register({
105
135
  apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
106
136
  githubAccessToken: github.accessToken, idempotencyKey,
107
- githubInstallationId, repositoryOwner, repositoryName,
137
+ githubInstallationId: resolvedInstallationId, repositoryOwner, repositoryName,
108
138
  topology: location.topology, canonicalBaseUrl: location.canonicalBaseUrl
109
139
  });
110
140
  await finalize(root, {
@@ -18,7 +18,14 @@ function apiUrl(apiBaseUrl) {
18
18
  return new URL('/v1/sites', base).href;
19
19
  }
20
20
 
21
- async function authorizeGitHub({ apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl }) {
21
+ /**
22
+ * Exchanges the stored GitHub token for the short-lived capability the API's GitHub-scoped
23
+ * endpoints require. Exported because publication creation needs the same capability, and two
24
+ * copies of an auth exchange is how they drift.
25
+ */
26
+ export async function exchangeGithubAuthorization({
27
+ apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
28
+ }) {
22
29
  if (typeof githubAccessToken !== 'string' || githubAccessToken === '') {
23
30
  throw new Error('GitHub authentication is missing; run `gala auth`');
24
31
  }
@@ -56,7 +63,7 @@ export async function registerSite({
56
63
  if (typeof galaAccessToken !== 'string' || galaAccessToken === '') {
57
64
  throw new Error('Gala authentication is missing; run `gala auth`');
58
65
  }
59
- const githubAuthorization = await authorizeGitHub({
66
+ const githubAuthorization = await exchangeGithubAuthorization({
60
67
  apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
61
68
  });
62
69
  required(idempotencyKey, 'idempotencyKey', IDEMPOTENCY_KEY);