@port60/template-kit 0.12.0 → 0.13.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/bin/cli.mjs +20 -0
- package/package.json +3 -9
- package/src/commands/auth.mjs +102 -0
- package/src/commands/publish.mjs +94 -0
- package/src/lib/auth.mjs +195 -0
- package/src/vendor/validator/platform-base.css +18 -0
package/bin/cli.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { model } from '../src/commands/model.mjs';
|
|
|
10
10
|
import { content } from '../src/commands/content.mjs';
|
|
11
11
|
import { upgrade } from '../src/commands/upgrade.mjs';
|
|
12
12
|
import { refresh } from '../src/commands/refresh.mjs';
|
|
13
|
+
import { login, logout, whoami } from '../src/commands/auth.mjs';
|
|
14
|
+
import { publish } from '../src/commands/publish.mjs';
|
|
13
15
|
|
|
14
16
|
function parseArgs(argv) {
|
|
15
17
|
const args = { _: [] };
|
|
@@ -59,6 +61,18 @@ switch (command) {
|
|
|
59
61
|
case 'package':
|
|
60
62
|
await packageCmd(args);
|
|
61
63
|
break;
|
|
64
|
+
case 'login':
|
|
65
|
+
await login(args);
|
|
66
|
+
break;
|
|
67
|
+
case 'logout':
|
|
68
|
+
await logout(args);
|
|
69
|
+
break;
|
|
70
|
+
case 'whoami':
|
|
71
|
+
await whoami(args);
|
|
72
|
+
break;
|
|
73
|
+
case 'publish':
|
|
74
|
+
await publish(args);
|
|
75
|
+
break;
|
|
62
76
|
default:
|
|
63
77
|
console.log(`@port60/template-kit, build Port60 site templates locally
|
|
64
78
|
|
|
@@ -73,6 +87,12 @@ usage:
|
|
|
73
87
|
p60-template-kit package [dir] validate + build the uploadable zip
|
|
74
88
|
p60-template-kit upgrade [dir] move an existing template to the latest kit
|
|
75
89
|
and contract, re-brief, re-validate
|
|
90
|
+
p60-template-kit login sign in from the terminal (the browser does the
|
|
91
|
+
actual sign-in; the code appears here)
|
|
92
|
+
p60-template-kit whoami who is signed in, and which studio workspace
|
|
93
|
+
p60-template-kit logout sign this machine out and revoke its token
|
|
94
|
+
p60-template-kit publish [dir] [--no-submit] validate, package, upload to your studio and
|
|
95
|
+
submit for review, in one command
|
|
76
96
|
|
|
77
97
|
AI agents: the scaffold's AGENTS.md is your briefing; iterate with \`validate --json\`.
|
|
78
98
|
Docs: https://developers.port60.com (agents: /llms-full.txt)`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@port60/template-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Build Port60 site templates locally: scaffold, live-preview, validate against the platform contract, and package for studio upload. AI-agent ready
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Build Port60 site templates locally: scaffold, live-preview, validate against the platform contract, and package for studio upload. AI-agent ready \u2014 every scaffold ships AGENTS.md and validate emits machine-readable JSON.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"p60-template-kit": "bin/cli.mjs"
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"starter"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
+
"sync-vendor": "node scripts/sync-vendor.mjs",
|
|
15
16
|
"test": "node --test 'tests/*.test.mjs'"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
@@ -22,12 +23,5 @@
|
|
|
22
23
|
"repository": {
|
|
23
24
|
"type": "git",
|
|
24
25
|
"url": "git+https://github.com/Port60Labs/template-kit.git"
|
|
25
|
-
},
|
|
26
|
-
"homepage": "https://developers.port60.com",
|
|
27
|
-
"bugs": {
|
|
28
|
-
"url": "https://github.com/Port60Labs/template-kit/issues"
|
|
29
|
-
},
|
|
30
|
-
"publishConfig": {
|
|
31
|
-
"access": "public"
|
|
32
26
|
}
|
|
33
27
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import {
|
|
3
|
+
accessToken, api, apiBase, authBase, authRealm, clearCredentials, decodeJwtPayload,
|
|
4
|
+
deviceLogin, loadCredentials, oauthClientId, requireCredentials, revoke, saveCredentials
|
|
5
|
+
} from '../lib/auth.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `login` / `logout` / `whoami` (developer CLI stream A). Login runs the device flow, then finds
|
|
9
|
+
* the developer's STUDIO workspace: memberships are product-blind, so the studio is discovered by
|
|
10
|
+
* probing the studio surface itself (the gateway's entitlement gate refuses non-studio workspaces,
|
|
11
|
+
* which is exactly the discriminator). One membership skips the ceremony; ambiguity asks once and
|
|
12
|
+
* the choice is remembered.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
async function studioProbe(creds, token, tenantId) {
|
|
16
|
+
try {
|
|
17
|
+
const res = await api(creds, token, `/api/admin/studio/tenants/${tenantId}/studio/status`);
|
|
18
|
+
return res.ok;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function login() {
|
|
25
|
+
const { accessToken: access, refreshToken } = await deviceLogin();
|
|
26
|
+
if (!refreshToken) {
|
|
27
|
+
console.error('✗ The sign-in completed but returned no offline token. Is offline_access enabled on the CLI client?');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const claims = decodeJwtPayload(access);
|
|
31
|
+
const creds = {
|
|
32
|
+
authBase: authBase(),
|
|
33
|
+
realm: authRealm(),
|
|
34
|
+
clientId: oauthClientId(),
|
|
35
|
+
apiBase: apiBase(),
|
|
36
|
+
refreshToken,
|
|
37
|
+
email: claims.email ?? null
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const res = await api(creds, access, '/api/me/memberships');
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
console.error(`✗ Signed in, but the workspace list could not be read (${res.status}).`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const memberships = await res.json();
|
|
46
|
+
if (memberships.length === 0) {
|
|
47
|
+
console.error('✗ This account has no workspaces. Create a developer account at https://port60.com/developers/ first.');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let workspace = null;
|
|
52
|
+
if (memberships.length === 1) {
|
|
53
|
+
workspace = memberships[0];
|
|
54
|
+
} else {
|
|
55
|
+
const probes = await Promise.all(
|
|
56
|
+
memberships.map(async (m) => ((await studioProbe(creds, access, m.tenantId)) ? m : null))
|
|
57
|
+
);
|
|
58
|
+
const studios = probes.filter(Boolean);
|
|
59
|
+
if (studios.length === 1) {
|
|
60
|
+
workspace = studios[0];
|
|
61
|
+
} else {
|
|
62
|
+
const candidates = studios.length > 0 ? studios : memberships;
|
|
63
|
+
console.log('\n Your workspaces:');
|
|
64
|
+
candidates.forEach((m, i) => console.log(` ${i + 1}. ${m.workspaceName}`));
|
|
65
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
66
|
+
const answer = await rl.question(' Which one is your template studio? ');
|
|
67
|
+
rl.close();
|
|
68
|
+
workspace = candidates[Number(answer) - 1];
|
|
69
|
+
if (!workspace) {
|
|
70
|
+
console.error('✗ No such option. Run login again.');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
saveCredentials({ ...creds, workspace: { tenantId: workspace.tenantId, name: workspace.workspaceName } });
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(`✓ Signed in${creds.email ? ` as ${creds.email}` : ''}.`);
|
|
79
|
+
console.log(` Studio workspace: ${workspace.workspaceName}`);
|
|
80
|
+
console.log(' Not the account you meant? Run: p60-template-kit logout, then login again.');
|
|
81
|
+
console.log(' Publish from a template directory with: p60-template-kit publish');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function whoami() {
|
|
85
|
+
const creds = requireCredentials();
|
|
86
|
+
const token = await accessToken(creds);
|
|
87
|
+
const claims = decodeJwtPayload(token);
|
|
88
|
+
console.log(`Signed in as ${claims.email ?? creds.email ?? claims.sub}`);
|
|
89
|
+
console.log(`Studio workspace: ${creds.workspace?.name ?? '(none chosen)'}`);
|
|
90
|
+
console.log(`Platform API: ${creds.apiBase}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function logout() {
|
|
94
|
+
const creds = loadCredentials();
|
|
95
|
+
if (!creds) {
|
|
96
|
+
console.log('Not signed in.');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
await revoke(creds);
|
|
100
|
+
clearCredentials();
|
|
101
|
+
console.log('✓ Signed out; this machine\'s token has been revoked.');
|
|
102
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { validateArtifact } from '../vendor/validator/validate.mjs';
|
|
3
|
+
import { loadArtifactDir } from '../lib/artifactFiles.mjs';
|
|
4
|
+
import { buildZip } from '../lib/zip.mjs';
|
|
5
|
+
import { accessToken, api, requireCredentials } from '../lib/auth.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `publish [dir]` (developer CLI stream A): validate, package and upload in one breath, then
|
|
9
|
+
* submit the version for review. The upload lane is manifest-routed, so an unclaimed name CREATES
|
|
10
|
+
* the template and a claimed one gains a version — the server runs the identical validation
|
|
11
|
+
* again, and a human reviews before anything can reach a charity's site. `--no-submit` stops at
|
|
12
|
+
* validated (submit later from the studio).
|
|
13
|
+
*/
|
|
14
|
+
export async function publish(args) {
|
|
15
|
+
const creds = requireCredentials();
|
|
16
|
+
if (!creds.workspace?.tenantId) {
|
|
17
|
+
console.error('✗ No studio workspace on record. Run: p60-template-kit login');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
const dir = resolve(args._[0] ?? '.');
|
|
21
|
+
const files = loadArtifactDir(dir);
|
|
22
|
+
const { errors, manifest } = await validateArtifact(files);
|
|
23
|
+
if (errors.length > 0) {
|
|
24
|
+
console.error(`✗ not publishing, ${errors.length} validation error${errors.length === 1 ? '' : 's'}:`);
|
|
25
|
+
for (const e of errors) console.error(` - ${e}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const zip = buildZip(Object.entries(files).map(([path, content]) => ({ path, content })));
|
|
29
|
+
console.log(`✓ ${manifest.name} ${manifest.version} validated, uploading…`);
|
|
30
|
+
|
|
31
|
+
const token = await accessToken(creds);
|
|
32
|
+
const studio = `/api/admin/studio/tenants/${creds.workspace.tenantId}/studio`;
|
|
33
|
+
const res = await api(creds, token, `${studio}/uploads`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/zip' },
|
|
36
|
+
body: zip
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const text = await res.text();
|
|
40
|
+
let message = text;
|
|
41
|
+
try {
|
|
42
|
+
const body = JSON.parse(text);
|
|
43
|
+
message = body.message ?? body.detail ?? text;
|
|
44
|
+
} catch {
|
|
45
|
+
// plain-text error, shown as-is
|
|
46
|
+
}
|
|
47
|
+
console.error(`✗ Upload refused (${res.status}): ${message}`);
|
|
48
|
+
if (res.status === 403) {
|
|
49
|
+
// The CLI holds the developer's OWN token; the server's permission model decides, exactly
|
|
50
|
+
// as in the studio UI. A role below the studio's minimum publishes nowhere.
|
|
51
|
+
console.error(' This account doesn\'t have permission to publish in that workspace.');
|
|
52
|
+
console.error(' Sign in with an account that administers the developer studio.');
|
|
53
|
+
}
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const outcome = await res.json();
|
|
57
|
+
const upload = outcome.upload ?? {};
|
|
58
|
+
if (outcome.templateCreated) {
|
|
59
|
+
console.log(`✓ ${outcome.templateLabel} created from its manifest.`);
|
|
60
|
+
}
|
|
61
|
+
if (!upload.accepted) {
|
|
62
|
+
console.error('✗ The platform rejected the version:');
|
|
63
|
+
for (const e of upload.errors ?? []) console.error(` - ${e}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
for (const w of upload.warnings ?? []) console.log(` ⚠ ${w}`);
|
|
67
|
+
console.log(`✓ Version ${upload.version} uploaded and validated.`);
|
|
68
|
+
|
|
69
|
+
if (args['no-submit']) {
|
|
70
|
+
console.log(' Left unsubmitted (--no-submit). Submit it for review from your studio.');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Submission needs the version row's id; the detail read supplies it.
|
|
75
|
+
const detailRes = await api(creds, token, `${studio}/templates/${outcome.templateId}`);
|
|
76
|
+
if (!detailRes.ok) {
|
|
77
|
+
console.error(`⚠ Uploaded, but the submit step could not read the template (${detailRes.status}). Submit from your studio.`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const detail = await detailRes.json();
|
|
81
|
+
const row = (detail.versions ?? []).find((v) => v.version === upload.version && v.status === 'VALIDATED');
|
|
82
|
+
if (!row) {
|
|
83
|
+
console.error('⚠ Uploaded, but the new version was not found in a submittable state. Submit from your studio.');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const submitRes = await api(creds, token, `${studio}/templates/${outcome.templateId}/versions/${row.id}/submit`, {
|
|
87
|
+
method: 'POST'
|
|
88
|
+
});
|
|
89
|
+
if (!submitRes.ok) {
|
|
90
|
+
console.error(`⚠ Uploaded, but submitting for review failed (${submitRes.status}). Submit from your studio.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.log('✓ Submitted for review. You\'ll get an email when a reviewer decides.');
|
|
94
|
+
}
|
package/src/lib/auth.mjs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The kit's sign-in plumbing (developer CLI stream A, docs/developer-cli-and-ci-publishing.md).
|
|
8
|
+
* The OAuth Device Authorization Grant against the platform's customer realm: the BROWSER does
|
|
9
|
+
* the actual sign-in (so passwordless email codes and Google both work unchanged), the terminal
|
|
10
|
+
* only ever holds the resulting tokens. The refresh token is an offline token, stored 0600 in
|
|
11
|
+
* ~/.port60/credentials.json and revocable from the account's sessions at any time.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const STORE_DIR = join(homedir(), '.port60');
|
|
15
|
+
const STORE_FILE = join(STORE_DIR, 'credentials.json');
|
|
16
|
+
|
|
17
|
+
export const authBase = () => (process.env.P60_AUTH_BASE || 'https://auth.port60.com').replace(/\/+$/, '');
|
|
18
|
+
export const authRealm = () => process.env.P60_REALM || 'port60-customers';
|
|
19
|
+
export const apiBase = () => (process.env.P60_API_BASE || 'https://api.port60.com').replace(/\/+$/, '');
|
|
20
|
+
export const oauthClientId = () => process.env.P60_CLIENT_ID || 'port60-kit';
|
|
21
|
+
|
|
22
|
+
const tokenUrl = (base, realm) => `${base}/realms/${realm}/protocol/openid-connect/token`;
|
|
23
|
+
|
|
24
|
+
export function loadCredentials() {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(STORE_FILE, 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveCredentials(creds) {
|
|
33
|
+
mkdirSync(STORE_DIR, { recursive: true, mode: 0o700 });
|
|
34
|
+
writeFileSync(STORE_FILE, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
|
|
35
|
+
chmodSync(STORE_FILE, 0o600); // writeFileSync mode is ignored when the file already exists
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clearCredentials() {
|
|
39
|
+
rmSync(STORE_FILE, { force: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function requireCredentials() {
|
|
43
|
+
const creds = loadCredentials();
|
|
44
|
+
if (!creds?.refreshToken) {
|
|
45
|
+
console.error('✗ Not signed in. Run: p60-template-kit login');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
return creds;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The JWT payload, unverified — display only (email, subject). The server verifies for real. */
|
|
52
|
+
export function decodeJwtPayload(token) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function form(url, params) {
|
|
61
|
+
const res = await fetch(url, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
64
|
+
body: new URLSearchParams(params)
|
|
65
|
+
});
|
|
66
|
+
const text = await res.text();
|
|
67
|
+
let body = {};
|
|
68
|
+
try {
|
|
69
|
+
body = JSON.parse(text);
|
|
70
|
+
} catch {
|
|
71
|
+
// non-JSON error page; the status carries the story
|
|
72
|
+
}
|
|
73
|
+
return { ok: res.ok, status: res.status, body };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A fresh access token from the stored offline token; rotates the stored refresh token when the
|
|
77
|
+
* server hands a new one back. */
|
|
78
|
+
export async function accessToken(creds) {
|
|
79
|
+
const { ok, status, body } = await form(tokenUrl(creds.authBase, creds.realm), {
|
|
80
|
+
grant_type: 'refresh_token',
|
|
81
|
+
client_id: creds.clientId,
|
|
82
|
+
refresh_token: creds.refreshToken
|
|
83
|
+
});
|
|
84
|
+
if (!ok) {
|
|
85
|
+
console.error(`✗ Your sign-in is no longer valid (${body.error ?? status}). Run: p60-template-kit login`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
if (body.refresh_token && body.refresh_token !== creds.refreshToken) {
|
|
89
|
+
saveCredentials({ ...creds, refreshToken: body.refresh_token });
|
|
90
|
+
}
|
|
91
|
+
return body.access_token;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function tryOpenBrowser(url) {
|
|
95
|
+
const opener = process.platform === 'darwin' ? 'open'
|
|
96
|
+
: process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
97
|
+
try {
|
|
98
|
+
spawn(opener, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
99
|
+
} catch {
|
|
100
|
+
// no browser to open; the printed URL is the path
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The device flow itself: ask for a code, hand the human to the browser, poll until the sign-in
|
|
108
|
+
* completes. Returns {accessToken, refreshToken}.
|
|
109
|
+
*/
|
|
110
|
+
export async function deviceLogin() {
|
|
111
|
+
const base = authBase();
|
|
112
|
+
const realm = authRealm();
|
|
113
|
+
const clientId = oauthClientId();
|
|
114
|
+
// prompt=login forces a fresh, intentional sign-in at the verification page even when the
|
|
115
|
+
// browser already holds an SSO session — so a developer who is also signed in as (say) a charity
|
|
116
|
+
// editor can never silently authorise the CLI as the wrong account. Login is rare (the offline
|
|
117
|
+
// token lasts), so paying for intentionality every time is the right trade.
|
|
118
|
+
const start = await form(`${base}/realms/${realm}/protocol/openid-connect/auth/device`, {
|
|
119
|
+
client_id: clientId,
|
|
120
|
+
scope: 'openid offline_access',
|
|
121
|
+
prompt: 'login'
|
|
122
|
+
});
|
|
123
|
+
if (!start.ok) {
|
|
124
|
+
console.error(`✗ Could not start the sign-in (${start.body.error_description ?? start.body.error ?? start.status}).`);
|
|
125
|
+
if (start.status === 404 || start.body.error === 'invalid_client') {
|
|
126
|
+
console.error(' The sign-in service may not have the CLI client enabled yet.');
|
|
127
|
+
}
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
const { device_code: deviceCode, user_code: userCode, verification_uri: uri,
|
|
131
|
+
verification_uri_complete: uriComplete, interval = 5, expires_in: expiresIn = 600 } = start.body;
|
|
132
|
+
|
|
133
|
+
console.log('');
|
|
134
|
+
console.log(` Visit: ${uri}`);
|
|
135
|
+
console.log(` Code: ${userCode}`);
|
|
136
|
+
console.log('');
|
|
137
|
+
console.log(' Sign in there with the account you want to PUBLISH AS — you will be asked to sign in');
|
|
138
|
+
console.log(' even if your browser is already logged in, so pick the right one. This terminal will notice.');
|
|
139
|
+
if (uriComplete) tryOpenBrowser(uriComplete);
|
|
140
|
+
|
|
141
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
142
|
+
let waitSeconds = interval;
|
|
143
|
+
while (Date.now() < deadline) {
|
|
144
|
+
await sleep(waitSeconds);
|
|
145
|
+
const poll = await form(tokenUrl(base, realm), {
|
|
146
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
147
|
+
client_id: clientId,
|
|
148
|
+
device_code: deviceCode
|
|
149
|
+
});
|
|
150
|
+
if (poll.ok) {
|
|
151
|
+
return { accessToken: poll.body.access_token, refreshToken: poll.body.refresh_token };
|
|
152
|
+
}
|
|
153
|
+
switch (poll.body.error) {
|
|
154
|
+
case 'authorization_pending':
|
|
155
|
+
break;
|
|
156
|
+
case 'slow_down':
|
|
157
|
+
waitSeconds += 5;
|
|
158
|
+
break;
|
|
159
|
+
case 'access_denied':
|
|
160
|
+
console.error('✗ The sign-in was declined in the browser.');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
break;
|
|
163
|
+
case 'expired_token':
|
|
164
|
+
console.error('✗ The code expired before the sign-in completed. Run login again.');
|
|
165
|
+
process.exit(1);
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
console.error(`✗ Sign-in failed (${poll.body.error_description ?? poll.body.error ?? poll.status}).`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
console.error('✗ The code expired before the sign-in completed. Run login again.');
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Best-effort server-side revocation of the offline token; local removal always follows. */
|
|
177
|
+
export async function revoke(creds) {
|
|
178
|
+
try {
|
|
179
|
+
await form(`${creds.authBase}/realms/${creds.realm}/protocol/openid-connect/logout`, {
|
|
180
|
+
client_id: creds.clientId,
|
|
181
|
+
refresh_token: creds.refreshToken
|
|
182
|
+
});
|
|
183
|
+
} catch {
|
|
184
|
+
// unreachable server; the local removal still signs this machine out
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** An authenticated platform-API call. */
|
|
189
|
+
export async function api(creds, token, path, init = {}) {
|
|
190
|
+
const res = await fetch(`${creds.apiBase}${path}`, {
|
|
191
|
+
...init,
|
|
192
|
+
headers: { authorization: `Bearer ${token}`, ...(init.headers ?? {}) }
|
|
193
|
+
});
|
|
194
|
+
return res;
|
|
195
|
+
}
|
|
@@ -4339,6 +4339,24 @@ textarea.p60-form-control {
|
|
|
4339
4339
|
.impact-map-card { margin-top: 14px; background: var(--surface); color: var(--ink);
|
|
4340
4340
|
border: 1px solid var(--line); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm);
|
|
4341
4341
|
padding: 16px 18px; max-width: 34rem; }
|
|
4342
|
+
/* On the LIVE map the below-map card is a SMALL-SCREEN device: the anchored popup is taller than a
|
|
4343
|
+
phone-height stage, so the marker selection drives this card instead (Google-Maps-mobile idiom).
|
|
4344
|
+
Desktop keeps the inline popup and hides this. The pin-canvas fallback (no modifier) shows the
|
|
4345
|
+
card on every screen size, as it always has. */
|
|
4346
|
+
.impact-map-card--small-screens { display: none; }
|
|
4347
|
+
@media (max-width: 640px) {
|
|
4348
|
+
/* Full width, flush under the full-bleed map — the two read as one attached sheet (the same
|
|
4349
|
+
breakout as the donate card; the hairline top border is the seam between map and card). */
|
|
4350
|
+
.impact-map-card--small-screens {
|
|
4351
|
+
display: block;
|
|
4352
|
+
width: 100vw;
|
|
4353
|
+
margin: 0 0 0 calc(50% - 50vw);
|
|
4354
|
+
max-width: none;
|
|
4355
|
+
border-radius: 0;
|
|
4356
|
+
border-left: 0;
|
|
4357
|
+
border-right: 0;
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4342
4360
|
.impact-map-card-kind { display: inline-block; font-size: 11px; font-weight: 700;
|
|
4343
4361
|
letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin-bottom: 4px; }
|
|
4344
4362
|
.impact-map-card-title { margin: 0 0 6px; }
|