@rathnasgala/cli 1.1.13 → 1.1.15
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/README.md +22 -10
- package/package.json +1 -1
- package/src/api/gala.js +9 -0
- package/src/auth/checkout-profile.js +105 -0
- package/src/auth/profiles.js +210 -0
- package/src/auth/store.js +4 -0
- package/src/commands/auth.js +35 -13
- package/src/commands/doctor.js +13 -4
- package/src/commands/domain.js +4 -4
- package/src/commands/init.js +7 -4
- package/src/commands/prism.js +4 -2
- package/src/commands/publish.js +4 -2
- package/src/commands-manifest.js +12 -9
package/README.md
CHANGED
|
@@ -12,6 +12,16 @@ Nothing to install. Every command runs through `npx`.
|
|
|
12
12
|
|
|
13
13
|
## Start a publication
|
|
14
14
|
|
|
15
|
+
Sign in once. The CLI binds the verified Gala and GitHub identities together, names the account
|
|
16
|
+
profile after the GitHub username, and makes it active.
|
|
17
|
+
|
|
18
|
+
```console
|
|
19
|
+
npx --yes @rathnasgala/cli@latest auth add
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Use `auth list` to inspect every pair, `auth use <github-login>` to change the active account, and
|
|
23
|
+
`auth remove <github-login>` to forget one. Tokens from different profiles are never combined.
|
|
24
|
+
|
|
15
25
|
Create it in the current empty folder:
|
|
16
26
|
|
|
17
27
|
```console
|
|
@@ -30,7 +40,7 @@ accepted and its `.git` directory is preserved. To reserve a custom domain durin
|
|
|
30
40
|
`--domain blog.example.com`; Gala still requires GitHub ownership verification and healthy DNS
|
|
31
41
|
before activating it.
|
|
32
42
|
|
|
33
|
-
It
|
|
43
|
+
It uses the selected profile, creates the repository, registers the
|
|
34
44
|
publication, and leaves a working checkout in the folder. GitHub starts the first deployment in the
|
|
35
45
|
background. The command links to that deployment rather than presenting the public address as live
|
|
36
46
|
before GitHub has finished.
|
|
@@ -126,18 +136,20 @@ Run any command with `--help`.
|
|
|
126
136
|
|
|
127
137
|
| Command | What it does | Options |
|
|
128
138
|
| --- | --- | --- |
|
|
129
|
-
| `init` | Create a publication in the current or named empty directory | `--name`, `--domain` |
|
|
130
|
-
| `domain` | Inspect or change the custom domain | `--root` |
|
|
139
|
+
| `init` | Create a publication in the current or named empty directory | `--name`, `--domain`, `--account` |
|
|
140
|
+
| `domain` | Inspect or change the custom domain | `--root`, `--account` |
|
|
131
141
|
| `new` | Start a post | `--language`, `--root`, `--today` |
|
|
132
142
|
| `preview` | Build and serve the publication locally | `--root`, `--today` |
|
|
133
|
-
| `publish` | Check, record and send your work to GitHub | `--root`, `--today`, `--skip-checks` |
|
|
134
|
-
| `prism` | Manage author-approved reading configurations | `--root`, `--language`, `--depth`, `--intent`, `--modality`, `--file`, `--reason`, `--yes` |
|
|
143
|
+
| `publish` | Check, record and send your work to GitHub | `--root`, `--today`, `--account`, `--skip-checks` |
|
|
144
|
+
| `prism` | Manage author-approved reading configurations | `--root`, `--account`, `--language`, `--depth`, `--intent`, `--modality`, `--file`, `--reason`, `--yes` |
|
|
135
145
|
| `upgrade` | Inspect and apply a verified managed-theme update | `--root`, `--channel`, `--yes` |
|
|
136
|
-
| `doctor` | Check a publication and say what is wrong | `--root` |
|
|
137
|
-
| `auth` |
|
|
146
|
+
| `doctor` | Check a publication and say what is wrong | `--root`, `--account` |
|
|
147
|
+
| `auth` | Add, list, select or remove GitHub-named account profiles | `--api-base-url` |
|
|
138
148
|
|
|
139
|
-
`
|
|
140
|
-
|
|
149
|
+
`init` shows both identities and uses the active account automatically. Registration stores that
|
|
150
|
+
profile binding inside the checkout's private `.git` metadata. Later authenticated commands use the
|
|
151
|
+
binding even if another profile becomes active. `--account` remains available for an explicit CI or
|
|
152
|
+
unbound-checkout selection, but it can never override a different bound owner.
|
|
141
153
|
|
|
142
154
|
Every command prompts for what it needs when run in a terminal, and every prompt has an option that
|
|
143
155
|
supplies it instead. With no terminal attached - in CI - nothing is ever prompted for: a value that
|
|
@@ -164,7 +176,7 @@ configured - so publishing works on a machine where those differ, or where none
|
|
|
164
176
|
Sign in again:
|
|
165
177
|
|
|
166
178
|
```console
|
|
167
|
-
npx --yes @rathnasgala/cli@latest auth
|
|
179
|
+
npx --yes @rathnasgala/cli@latest auth add
|
|
168
180
|
```
|
|
169
181
|
|
|
170
182
|
### Gala cannot reach the repository
|
package/package.json
CHANGED
package/src/api/gala.js
CHANGED
|
@@ -38,6 +38,15 @@ export function galaApi({ baseUrl = DEFAULT_API_BASE_URL, token } = {}) {
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
|
|
41
|
+
async profile() {
|
|
42
|
+
const body = await requestJson(`${root}/v1/me`, authorized('Gala account lookup'));
|
|
43
|
+
if (typeof body?.userId !== 'string' || typeof body?.email !== 'string'
|
|
44
|
+
|| typeof body?.displayName !== 'string') {
|
|
45
|
+
throw new TypeError('Gala returned an unusable account identity');
|
|
46
|
+
}
|
|
47
|
+
return { userId: body.userId, email: body.email, displayName: body.displayName };
|
|
48
|
+
},
|
|
49
|
+
|
|
41
50
|
/** Exchanges the GitHub token for the short-lived capability the GitHub-scoped routes require. */
|
|
42
51
|
async githubCapability(githubToken) {
|
|
43
52
|
const body = await requestJson(`${root}/v1/auth/github/device-authorizations`,
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { UsageError } from '../cli/args.js';
|
|
5
|
+
import { readPublication } from '../publication.js';
|
|
6
|
+
import { listProfiles, requireProfileName } from './profiles.js';
|
|
7
|
+
|
|
8
|
+
const FILENAME = 'gala-account-profile';
|
|
9
|
+
|
|
10
|
+
export async function bindCheckoutProfile(root, name) {
|
|
11
|
+
const gitDirectory = path.join(path.resolve(root), '.git');
|
|
12
|
+
const metadata = await stat(gitDirectory).catch(() => null);
|
|
13
|
+
if (!metadata?.isDirectory()) {
|
|
14
|
+
throw new Error('Cannot bind the account profile because this is not a standard Git checkout.');
|
|
15
|
+
}
|
|
16
|
+
const target = path.join(gitDirectory, FILENAME);
|
|
17
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
18
|
+
try {
|
|
19
|
+
await writeFile(temporary, `${requireProfileName(name)}\n`, { flag: 'wx', mode: 0o600 });
|
|
20
|
+
await rename(temporary, target);
|
|
21
|
+
} catch (failure) {
|
|
22
|
+
await rm(temporary, { force: true });
|
|
23
|
+
throw failure;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function checkoutProfile(root) {
|
|
28
|
+
try {
|
|
29
|
+
return requireProfileName((await readFile(
|
|
30
|
+
path.join(path.resolve(root), '.git', FILENAME), 'utf8'
|
|
31
|
+
)).trim());
|
|
32
|
+
} catch (failure) {
|
|
33
|
+
if (failure?.code === 'ENOENT') return null;
|
|
34
|
+
throw failure;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function accountForCommand(options, root, { terminal, profilesRoot } = {}) {
|
|
39
|
+
const explicit = options.value('account');
|
|
40
|
+
const bound = await checkoutProfile(root);
|
|
41
|
+
if (explicit != null) {
|
|
42
|
+
const selected = await resolveProfileSelector(explicit, profilesRoot);
|
|
43
|
+
if (bound != null && bound !== selected) {
|
|
44
|
+
throw new UsageError(
|
|
45
|
+
`This checkout belongs to account profile ${bound}; --account ${selected} cannot override it.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (bound == null) await bindIfCheckout(root, selected);
|
|
49
|
+
return selected;
|
|
50
|
+
}
|
|
51
|
+
if (bound != null) return bound;
|
|
52
|
+
|
|
53
|
+
const profiles = await listProfiles(profilesRoot == null ? {} : { root: profilesRoot });
|
|
54
|
+
if (profiles.length === 0) {
|
|
55
|
+
throw new UsageError('No account profile exists. Run `npx --yes @rathnasgala/cli@latest auth add`.');
|
|
56
|
+
}
|
|
57
|
+
const publication = await readPublication(root);
|
|
58
|
+
const repositoryOwner = typeof publication?.repository === 'string'
|
|
59
|
+
? publication.repository.split('/', 1)[0]?.toLowerCase()
|
|
60
|
+
: null;
|
|
61
|
+
const ownerMatches = repositoryOwner == null ? [] : profiles.filter((profile) =>
|
|
62
|
+
profile.githubLogin.toLowerCase() === repositoryOwner);
|
|
63
|
+
let selected;
|
|
64
|
+
if (ownerMatches.length === 1) selected = ownerMatches[0].name;
|
|
65
|
+
else if (profiles.length === 1) selected = profiles[0].name;
|
|
66
|
+
else {
|
|
67
|
+
if (!terminal?.interactive) {
|
|
68
|
+
throw new UsageError(
|
|
69
|
+
`This repository could use more than one account profile (${profileChoices(profiles)}); pass --account <profile>.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const answer = await terminal.ask(`Which account owns this repository? (${profileChoices(profiles)})`);
|
|
73
|
+
selected = resolveFromProfiles(answer, profiles);
|
|
74
|
+
}
|
|
75
|
+
await bindIfCheckout(root, selected);
|
|
76
|
+
terminal?.note(`Using account profile ${selected} for this repository.`);
|
|
77
|
+
return selected;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function resolveProfileSelector(value, profilesRoot) {
|
|
81
|
+
const profiles = await listProfiles(profilesRoot == null ? {} : { root: profilesRoot });
|
|
82
|
+
return resolveFromProfiles(value, profiles);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveFromProfiles(value, profiles) {
|
|
86
|
+
const selector = String(value ?? '').trim().replace(/^@/, '').toLowerCase();
|
|
87
|
+
const matches = profiles.filter(({ name, githubLogin, gala: identity }) => name === selector
|
|
88
|
+
|| githubLogin.toLowerCase() === selector
|
|
89
|
+
|| identity.email.toLowerCase() === selector);
|
|
90
|
+
if (matches.length === 1) return matches[0].name;
|
|
91
|
+
if (matches.length > 1) {
|
|
92
|
+
throw new UsageError(`Account ${value} matches more than one profile; use one of: ${profileChoices(matches)}.`);
|
|
93
|
+
}
|
|
94
|
+
throw new UsageError(`Account ${value} is not configured; use one of: ${profileChoices(profiles)}.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function profileChoices(profiles) {
|
|
98
|
+
return profiles.map(({ name, githubLogin, gala: identity }) =>
|
|
99
|
+
`${name} (GitHub @${githubLogin}, Gala ${identity.email})`).join(', ');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function bindIfCheckout(root, selected) {
|
|
103
|
+
const metadata = await stat(path.join(path.resolve(root), '.git')).catch(() => null);
|
|
104
|
+
if (metadata?.isDirectory()) await bindCheckoutProfile(root, selected);
|
|
105
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { galaApi } from '../api/gala.js';
|
|
5
|
+
import { githubApi } from '../api/github.js';
|
|
6
|
+
import { UsageError } from '../cli/args.js';
|
|
7
|
+
import { galaCredential } from './gala.js';
|
|
8
|
+
import { githubCredential } from './github.js';
|
|
9
|
+
import { credentialDirectory, readCredential } from './store.js';
|
|
10
|
+
|
|
11
|
+
const PROFILE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
12
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
13
|
+
const STORE_VERSION = '2';
|
|
14
|
+
|
|
15
|
+
export function requireProfileName(value) {
|
|
16
|
+
if (typeof value !== 'string' || !PROFILE.test(value)) {
|
|
17
|
+
throw new UsageError('Account profile names use lowercase letters, numbers and single hyphens.');
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function profilePaths(name, { root = credentialDirectory() } = {}) {
|
|
23
|
+
const safe = requireProfileName(name);
|
|
24
|
+
const directory = path.join(root, 'profiles', safe);
|
|
25
|
+
return {
|
|
26
|
+
directory,
|
|
27
|
+
metadata: path.join(directory, 'profile.json'),
|
|
28
|
+
gala: path.join(directory, 'gala.json'),
|
|
29
|
+
github: path.join(directory, 'github.json'),
|
|
30
|
+
active: path.join(root, 'active-profile'),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function addProfile({
|
|
35
|
+
terminal,
|
|
36
|
+
apiBaseUrl,
|
|
37
|
+
root = credentialDirectory(),
|
|
38
|
+
galaSignIn = galaCredential,
|
|
39
|
+
githubSignIn = githubCredential,
|
|
40
|
+
galaLookup = async (credential) => galaApi({
|
|
41
|
+
baseUrl: credential.apiBaseUrl,
|
|
42
|
+
token: credential.accessToken,
|
|
43
|
+
}).profile(),
|
|
44
|
+
githubLookup = async (credential) => githubApi(credential.accessToken).viewer(),
|
|
45
|
+
}) {
|
|
46
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
47
|
+
const pending = await mkdtemp(path.join(root, '.pending-profile-'));
|
|
48
|
+
await chmod(pending, 0o700);
|
|
49
|
+
const pendingPaths = {
|
|
50
|
+
gala: path.join(pending, 'gala.json'),
|
|
51
|
+
github: path.join(pending, 'github.json'),
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
const gala = await galaSignIn({ terminal, apiBaseUrl, target: pendingPaths.gala });
|
|
55
|
+
const galaIdentity = await galaLookup(gala);
|
|
56
|
+
const github = await githubSignIn({ terminal, target: pendingPaths.github });
|
|
57
|
+
const githubLogin = await githubLookup(github);
|
|
58
|
+
if (typeof githubLogin !== 'string') {
|
|
59
|
+
throw new UsageError('GitHub did not return an account username.');
|
|
60
|
+
}
|
|
61
|
+
const name = requireProfileName(githubLogin.toLowerCase());
|
|
62
|
+
const metadata = normalizeMetadata({ schemaVersion: 2, name, gala: galaIdentity, githubLogin });
|
|
63
|
+
await atomicJson(path.join(pending, 'profile.json'), metadata);
|
|
64
|
+
await prepareStore(root);
|
|
65
|
+
const paths = profilePaths(name, { root });
|
|
66
|
+
await rm(paths.directory, { recursive: true, force: true });
|
|
67
|
+
await rename(pending, paths.directory);
|
|
68
|
+
await setActiveProfile(name, { root });
|
|
69
|
+
return { metadata, gala, github };
|
|
70
|
+
} catch (failure) {
|
|
71
|
+
await rm(pending, { recursive: true, force: true });
|
|
72
|
+
throw failure;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function listProfiles({ root = credentialDirectory() } = {}) {
|
|
77
|
+
const directory = path.join(root, 'profiles');
|
|
78
|
+
let names;
|
|
79
|
+
try {
|
|
80
|
+
names = await readdir(directory);
|
|
81
|
+
} catch (failure) {
|
|
82
|
+
if (failure?.code === 'ENOENT') return [];
|
|
83
|
+
throw failure;
|
|
84
|
+
}
|
|
85
|
+
const active = await activeProfile({ root });
|
|
86
|
+
const profiles = [];
|
|
87
|
+
for (const name of names.sort()) {
|
|
88
|
+
try {
|
|
89
|
+
const metadata = JSON.parse(await readFile(profilePaths(name, { root }).metadata, 'utf8'));
|
|
90
|
+
profiles.push({ ...normalizeMetadata(metadata), active: name === active });
|
|
91
|
+
} catch (failure) {
|
|
92
|
+
if (failure?.code !== 'ENOENT' && !(failure instanceof SyntaxError)) throw failure;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return profiles;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function useProfile(name, { root = credentialDirectory() } = {}) {
|
|
99
|
+
const selected = await readProfile(name, { root });
|
|
100
|
+
await setActiveProfile(selected.metadata.name, { root });
|
|
101
|
+
return selected.metadata;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function removeProfile(name, { root = credentialDirectory() } = {}) {
|
|
105
|
+
const paths = profilePaths(name, { root });
|
|
106
|
+
try {
|
|
107
|
+
const metadata = await stat(paths.directory);
|
|
108
|
+
if (!metadata.isDirectory()) throw new UsageError(`Account profile ${name} is unavailable.`);
|
|
109
|
+
} catch (failure) {
|
|
110
|
+
if (failure?.code === 'ENOENT') throw new UsageError(`Account profile ${name} does not exist.`);
|
|
111
|
+
throw failure;
|
|
112
|
+
}
|
|
113
|
+
await rm(paths.directory, { recursive: true });
|
|
114
|
+
if (await activeProfile({ root }) === name) await rm(paths.active, { force: true });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function selectedProfile({ name, root = credentialDirectory() } = {}) {
|
|
118
|
+
const selected = name == null ? await activeProfile({ root }) : requireProfileName(name);
|
|
119
|
+
if (selected == null) {
|
|
120
|
+
throw new UsageError('No account profile is active. Run `npx --yes @rathnasgala/cli@latest auth add`.');
|
|
121
|
+
}
|
|
122
|
+
return readProfile(selected, { root });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function activeProfile({ root = credentialDirectory() } = {}) {
|
|
126
|
+
try {
|
|
127
|
+
const value = (await readFile(path.join(root, 'active-profile'), 'utf8')).trim();
|
|
128
|
+
return requireProfileName(value);
|
|
129
|
+
} catch (failure) {
|
|
130
|
+
if (failure?.code === 'ENOENT') return null;
|
|
131
|
+
throw failure;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readProfile(name, { root }) {
|
|
136
|
+
const paths = profilePaths(name, { root });
|
|
137
|
+
let metadata;
|
|
138
|
+
try {
|
|
139
|
+
metadata = normalizeMetadata(JSON.parse(await readFile(paths.metadata, 'utf8')));
|
|
140
|
+
} catch (failure) {
|
|
141
|
+
if (failure?.code === 'ENOENT' || failure instanceof SyntaxError) {
|
|
142
|
+
throw new UsageError(`Account profile ${name} is incomplete; remove it and add it again.`);
|
|
143
|
+
}
|
|
144
|
+
throw failure;
|
|
145
|
+
}
|
|
146
|
+
const [gala, github] = await Promise.all([readCredential(paths.gala), readCredential(paths.github)]);
|
|
147
|
+
if (gala == null || github == null) {
|
|
148
|
+
throw new UsageError(
|
|
149
|
+
`Account profile ${name} has expired; run \`npx --yes @rathnasgala/cli@latest auth add\` to sign in again.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return { metadata, gala, github };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function setActiveProfile(name, { root }) {
|
|
156
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
157
|
+
await atomicText(path.join(root, 'active-profile'), `${requireProfileName(name)}\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeMetadata(value) {
|
|
161
|
+
const name = requireProfileName(value?.name);
|
|
162
|
+
if (value?.schemaVersion !== 2) {
|
|
163
|
+
throw new UsageError(`Account profile ${name} has an unsupported format.`);
|
|
164
|
+
}
|
|
165
|
+
const userId = value?.gala?.userId;
|
|
166
|
+
const email = value?.gala?.email;
|
|
167
|
+
const displayName = value?.gala?.displayName;
|
|
168
|
+
const githubLogin = value?.githubLogin;
|
|
169
|
+
if (typeof userId !== 'string' || !ULID.test(userId)
|
|
170
|
+
|| typeof email !== 'string' || email === ''
|
|
171
|
+
|| typeof displayName !== 'string' || displayName === ''
|
|
172
|
+
|| typeof githubLogin !== 'string' || githubLogin === '') {
|
|
173
|
+
throw new UsageError(`Account profile ${name} has invalid identity metadata.`);
|
|
174
|
+
}
|
|
175
|
+
if (githubLogin.toLowerCase() !== name) {
|
|
176
|
+
throw new UsageError(`Account profile ${name} does not match its GitHub identity.`);
|
|
177
|
+
}
|
|
178
|
+
return { schemaVersion: 2, name, gala: { userId, email, displayName }, githubLogin };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function prepareStore(root) {
|
|
182
|
+
const version = path.join(root, 'profile-store-version');
|
|
183
|
+
const current = await readFile(version, 'utf8').catch((failure) => {
|
|
184
|
+
if (failure?.code === 'ENOENT') return null;
|
|
185
|
+
throw failure;
|
|
186
|
+
});
|
|
187
|
+
if (current?.trim() !== STORE_VERSION) {
|
|
188
|
+
await rm(path.join(root, 'profiles'), { recursive: true, force: true });
|
|
189
|
+
await rm(path.join(root, 'active-profile'), { force: true });
|
|
190
|
+
await rm(path.join(root, 'credentials.json'), { force: true });
|
|
191
|
+
await rm(path.join(root, 'github-credentials.json'), { force: true });
|
|
192
|
+
await atomicText(version, `${STORE_VERSION}\n`);
|
|
193
|
+
}
|
|
194
|
+
await mkdir(path.join(root, 'profiles'), { recursive: true, mode: 0o700 });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function atomicJson(target, value) {
|
|
198
|
+
return atomicText(target, `${JSON.stringify(value)}\n`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function atomicText(target, value) {
|
|
202
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
203
|
+
try {
|
|
204
|
+
await writeFile(temporary, value, { flag: 'wx', mode: 0o600 });
|
|
205
|
+
await rename(temporary, target);
|
|
206
|
+
} catch (failure) {
|
|
207
|
+
await rm(temporary, { force: true });
|
|
208
|
+
throw failure;
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/auth/store.js
CHANGED
|
@@ -31,6 +31,10 @@ export function credentialPath(name, { platform = process.platform, environment
|
|
|
31
31
|
return path.join(environment.XDG_CONFIG_HOME || path.join(home, '.config'), 'gala', `${name}.json`);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export function credentialDirectory(options = {}) {
|
|
35
|
+
return path.dirname(credentialPath('credentials', options));
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
export async function writeCredential(target, record) {
|
|
35
39
|
const directory = path.dirname(path.resolve(target));
|
|
36
40
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
package/src/commands/auth.js
CHANGED
|
@@ -1,20 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { addProfile, listProfiles, removeProfile, useProfile } from '../auth/profiles.js';
|
|
2
|
+
import { UsageError } from '../cli/args.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Signs in to both, and says so.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* or after a token has expired.
|
|
7
|
+
* One login creates and activates the GitHub-named pair. A writer returns here only for a new
|
|
8
|
+
* machine, a different account, or an expired token.
|
|
10
9
|
*/
|
|
11
10
|
export async function auth({ terminal, options }) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
const [action = 'list', name, ...extra] = options.positional;
|
|
12
|
+
if (extra.length > 0 || !['list', 'add', 'use', 'remove'].includes(action)) {
|
|
13
|
+
throw new UsageError('Use: auth [list|add|use <github-login>|remove <github-login>]');
|
|
14
|
+
}
|
|
15
|
+
if (action === 'list') {
|
|
16
|
+
if (name != null) throw new UsageError('auth list takes no profile name');
|
|
17
|
+
const profiles = await listProfiles();
|
|
18
|
+
if (profiles.length === 0) {
|
|
19
|
+
terminal.note('No account profiles. Run auth add.');
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
for (const profile of profiles) {
|
|
23
|
+
terminal.done(`${profile.active ? '*' : ' '} ${profile.name}: Gala ${profile.gala.email} + GitHub @${profile.githubLogin}`);
|
|
24
|
+
}
|
|
25
|
+
return profiles;
|
|
26
|
+
}
|
|
27
|
+
if (action === 'add') {
|
|
28
|
+
if (name != null) throw new UsageError('auth add takes no profile name; it uses your GitHub username');
|
|
29
|
+
const profile = await addProfile({ terminal, apiBaseUrl: options.value('api-base-url') });
|
|
30
|
+
terminal.done(`Using ${profile.metadata.name}: Gala ${profile.metadata.gala.email} + GitHub @${profile.metadata.githubLogin}`);
|
|
31
|
+
return profile.metadata;
|
|
32
|
+
}
|
|
33
|
+
if (name == null) throw new UsageError(`auth ${action} needs a GitHub username`);
|
|
34
|
+
if (action === 'use') {
|
|
35
|
+
const profile = await useProfile(name);
|
|
36
|
+
terminal.done(`Using ${name}: Gala ${profile.gala.email} + GitHub @${profile.githubLogin}`);
|
|
37
|
+
return profile;
|
|
38
|
+
}
|
|
39
|
+
await removeProfile(name);
|
|
40
|
+
terminal.done(`Removed account profile ${name}`);
|
|
41
|
+
return null;
|
|
20
42
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -2,8 +2,8 @@ import { readFile, readdir, stat } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { galaApi } from '../api/gala.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { accountForCommand } from '../auth/checkout-profile.js';
|
|
6
|
+
import { selectedProfile } from '../auth/profiles.js';
|
|
7
7
|
import { cliCommand } from '../cli/invocation.js';
|
|
8
8
|
import { createGit } from '../git.js';
|
|
9
9
|
|
|
@@ -19,8 +19,16 @@ export async function doctor({ terminal, options, cwd = process.cwd() }) {
|
|
|
19
19
|
const root = path.resolve(options.value('root') ?? cwd);
|
|
20
20
|
const checks = [];
|
|
21
21
|
|
|
22
|
+
let selected;
|
|
23
|
+
checks.push(await checkCredential('Account profile', async () => {
|
|
24
|
+
const account = await accountForCommand(options, root, { terminal });
|
|
25
|
+
selected = await selectedProfile({ name: account });
|
|
26
|
+
return ok(`${account}: Gala ${selected.metadata.gala.email} + GitHub @${selected.metadata.githubLogin}`);
|
|
27
|
+
}));
|
|
28
|
+
|
|
22
29
|
checks.push(await checkCredential('Gala sign-in', async () => {
|
|
23
|
-
|
|
30
|
+
if (selected == null) throw new Error('account profile is unavailable');
|
|
31
|
+
const gala = selected.gala;
|
|
24
32
|
const accepted = await galaApi({ baseUrl: gala.apiBaseUrl, token: gala.accessToken }).accepted();
|
|
25
33
|
return accepted
|
|
26
34
|
? ok(`valid until ${new Date(gala.expiresAt).toLocaleString()}`)
|
|
@@ -28,7 +36,8 @@ export async function doctor({ terminal, options, cwd = process.cwd() }) {
|
|
|
28
36
|
}));
|
|
29
37
|
|
|
30
38
|
checks.push(await checkCredential('GitHub sign-in', async () => {
|
|
31
|
-
|
|
39
|
+
if (selected == null) throw new Error('account profile is unavailable');
|
|
40
|
+
const github = selected.github;
|
|
32
41
|
return ok(github.expiresAt
|
|
33
42
|
? `valid until ${new Date(github.expiresAt).toLocaleString()}`
|
|
34
43
|
: 'signed in');
|
package/src/commands/domain.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
3
|
import { galaApi } from '../api/gala.js';
|
|
4
|
-
import {
|
|
4
|
+
import { accountForCommand } from '../auth/checkout-profile.js';
|
|
5
|
+
import { selectedProfile } from '../auth/profiles.js';
|
|
5
6
|
import { UsageError } from '../cli/args.js';
|
|
6
7
|
import { cliCommand } from '../cli/invocation.js';
|
|
7
8
|
import { customDomain } from '../domain.js';
|
|
@@ -22,9 +23,8 @@ export async function domain({ terminal, options, cwd = process.cwd() }) {
|
|
|
22
23
|
if (action === 'set' && value == null) throw new UsageError('domain set needs a hostname');
|
|
23
24
|
if (action !== 'set' && value != null) throw new UsageError(`domain ${action} takes no hostname`);
|
|
24
25
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
});
|
|
26
|
+
const account = await accountForCommand(options, root, { terminal });
|
|
27
|
+
const credential = (await selectedProfile({ name: account })).gala;
|
|
28
28
|
const api = galaApi({ baseUrl: credential.apiBaseUrl, token: credential.accessToken });
|
|
29
29
|
|
|
30
30
|
if (action === 'status') {
|
package/src/commands/init.js
CHANGED
|
@@ -3,8 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import { galaApi } from '../api/gala.js';
|
|
5
5
|
import { githubApi } from '../api/github.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { accountForCommand, bindCheckoutProfile } from '../auth/checkout-profile.js';
|
|
7
|
+
import { selectedProfile } from '../auth/profiles.js';
|
|
8
8
|
import { cloneRepository, createGit, populateEmptyRepository } from '../git.js';
|
|
9
9
|
import { UsageError } from '../cli/args.js';
|
|
10
10
|
import { CLI_INVOCATION, shellArgument } from '../cli/invocation.js';
|
|
@@ -45,8 +45,10 @@ export async function init({ terminal, options, cwd = process.cwd() }) {
|
|
|
45
45
|
|
|
46
46
|
const destination = await inspectDestination(directory);
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
const github
|
|
48
|
+
const profile = await selectedProfile({ name: options.value('account') });
|
|
49
|
+
const { gala, github, metadata } = profile;
|
|
50
|
+
const account = metadata.name;
|
|
51
|
+
terminal.step(`Account ${account}: Gala ${metadata.gala.email} + GitHub @${metadata.githubLogin}`);
|
|
50
52
|
|
|
51
53
|
const name = await publicationName({ terminal, explicitName, directory });
|
|
52
54
|
|
|
@@ -71,6 +73,7 @@ export async function init({ terminal, options, cwd = process.cwd() }) {
|
|
|
71
73
|
if (destination === 'empty-git') await populateEmptyRepository(checkout);
|
|
72
74
|
else await cloneRepository(checkout);
|
|
73
75
|
|
|
76
|
+
await bindCheckoutProfile(directory, account);
|
|
74
77
|
terminal.step('Registering the publication');
|
|
75
78
|
const git = createGit({ root: directory, token: github.accessToken });
|
|
76
79
|
const registration = await api.registerSite({
|
package/src/commands/prism.js
CHANGED
|
@@ -2,7 +2,8 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { galaApi } from '../api/gala.js';
|
|
5
|
-
import {
|
|
5
|
+
import { accountForCommand } from '../auth/checkout-profile.js';
|
|
6
|
+
import { selectedProfile } from '../auth/profiles.js';
|
|
6
7
|
import { UsageError } from '../cli/args.js';
|
|
7
8
|
import { cliCommand } from '../cli/invocation.js';
|
|
8
9
|
import { createGit } from '../git.js';
|
|
@@ -21,7 +22,8 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
|
|
|
21
22
|
if (!publication || !ULID.test(publication.siteId ?? '')) {
|
|
22
23
|
throw new UsageError('Run this inside a registered Gala publication, or pass --root.');
|
|
23
24
|
}
|
|
24
|
-
const
|
|
25
|
+
const account = await accountForCommand(options, root, { terminal });
|
|
26
|
+
const credential = (await selectedProfile({ name: account })).gala;
|
|
25
27
|
const api = galaApi({ baseUrl: credential.apiBaseUrl, token: credential.accessToken });
|
|
26
28
|
const [action = 'status', ...args] = options.positional;
|
|
27
29
|
|
package/src/commands/publish.js
CHANGED
|
@@ -2,7 +2,8 @@ import path from 'node:path';
|
|
|
2
2
|
|
|
3
3
|
import { checkContent } from '../content.js';
|
|
4
4
|
import { createGit } from '../git.js';
|
|
5
|
-
import {
|
|
5
|
+
import { accountForCommand } from '../auth/checkout-profile.js';
|
|
6
|
+
import { selectedProfile } from '../auth/profiles.js';
|
|
6
7
|
import { readPublication } from '../publication.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -25,7 +26,8 @@ export async function publish({ terminal, options, cwd = process.cwd(), regenera
|
|
|
25
26
|
const root = path.resolve(options.value('root') ?? cwd);
|
|
26
27
|
const today = options.value('today');
|
|
27
28
|
|
|
28
|
-
const
|
|
29
|
+
const account = await accountForCommand(options, root, { terminal });
|
|
30
|
+
const github = (await selectedProfile({ name: account })).github;
|
|
29
31
|
const git = createGit({ root, token: github.accessToken });
|
|
30
32
|
|
|
31
33
|
terminal.step('Catching up with GitHub');
|
package/src/commands-manifest.js
CHANGED
|
@@ -26,20 +26,21 @@ import { cliCommand } from './cli/invocation.js';
|
|
|
26
26
|
*/
|
|
27
27
|
export const COMMANDS = {
|
|
28
28
|
auth: {
|
|
29
|
-
summary: '
|
|
29
|
+
summary: 'Add, inspect, select or remove account profiles',
|
|
30
|
+
usage: cliCommand('auth [list|add|use <github-login>|remove <github-login>] [--api-base-url URL]'),
|
|
30
31
|
flags: ['api-base-url'],
|
|
31
32
|
run: auth
|
|
32
33
|
},
|
|
33
34
|
init: {
|
|
34
35
|
summary: 'Create a publication in an empty directory',
|
|
35
|
-
usage: cliCommand('init [directory] [--name my-notes] [--domain blog.example.com]'),
|
|
36
|
-
flags: ['name', 'domain', '
|
|
36
|
+
usage: cliCommand('init [directory] [--name my-notes] [--domain blog.example.com] [--account github-login]'),
|
|
37
|
+
flags: ['name', 'domain', 'account'],
|
|
37
38
|
run: init
|
|
38
39
|
},
|
|
39
40
|
domain: {
|
|
40
41
|
summary: 'Inspect or change this publication’s custom domain',
|
|
41
|
-
usage: cliCommand('domain [status|set <hostname>|check|cancel|remove] [--root path]'),
|
|
42
|
-
flags: ['root', '
|
|
42
|
+
usage: cliCommand('domain [status|set <hostname>|check|cancel|remove] [--root path] [--account github-login]'),
|
|
43
|
+
flags: ['root', 'account'],
|
|
43
44
|
run: domain
|
|
44
45
|
},
|
|
45
46
|
new: {
|
|
@@ -55,14 +56,15 @@ export const COMMANDS = {
|
|
|
55
56
|
},
|
|
56
57
|
publish: {
|
|
57
58
|
summary: 'Check, record and send your work to GitHub',
|
|
58
|
-
|
|
59
|
+
usage: cliCommand('publish [--root path] [--today YYYY-MM-DD] [--account github-login] [--skip-checks]'),
|
|
60
|
+
flags: ['root', 'today', 'account'],
|
|
59
61
|
switches: ['skip-checks'],
|
|
60
62
|
run: publish
|
|
61
63
|
},
|
|
62
64
|
prism: {
|
|
63
65
|
summary: 'Manage author-approved Prism configurations',
|
|
64
|
-
usage: cliCommand('prism <status|mode|link-policy|list|create|edit|generate|submit|approve|reject|revoke> [arguments]'),
|
|
65
|
-
flags: ['root', '
|
|
66
|
+
usage: cliCommand('prism <status|mode|link-policy|list|create|edit|generate|submit|approve|reject|revoke> [arguments] [--account github-login]'),
|
|
67
|
+
flags: ['root', 'account', 'language', 'depth', 'intent', 'modality', 'file', 'reason'],
|
|
66
68
|
switches: ['yes'],
|
|
67
69
|
run: prism
|
|
68
70
|
},
|
|
@@ -75,7 +77,8 @@ export const COMMANDS = {
|
|
|
75
77
|
},
|
|
76
78
|
doctor: {
|
|
77
79
|
summary: 'Check a publication and say what is wrong',
|
|
78
|
-
|
|
80
|
+
usage: cliCommand('doctor [--root path] [--account github-login]'),
|
|
81
|
+
flags: ['root', 'account'],
|
|
79
82
|
run: doctor
|
|
80
83
|
}
|
|
81
84
|
};
|