@port60/template-kit 0.12.0 → 0.14.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 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,14 @@ 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
96
+ p60-template-kit publish [dir] --ci the same from a GitHub Actions workflow: no
97
+ login, the run's own OIDC token is the credential
76
98
 
77
99
  AI agents: the scaffold's AGENTS.md is your briefing; iterate with \`validate --json\`.
78
100
  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.12.0",
4
- "description": "Build Port60 site templates locally: scaffold, live-preview, validate against the platform contract, and package for studio upload. AI-agent ready — every scaffold ships AGENTS.md and validate emits machine-readable JSON.",
3
+ "version": "0.14.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
+ }
@@ -42,7 +42,7 @@ export function create(args) {
42
42
  const briefing = agentsMd(name);
43
43
  writeFileSync(join(target, 'AGENTS.md'), briefing);
44
44
  writeFileSync(join(target, 'CLAUDE.md'), briefing);
45
- writeFileSync(join(target, '.gitignore'), 'node_modules/\n*.zip\n');
45
+ writeFileSync(join(target, '.gitignore'), 'node_modules/\ndist/\n');
46
46
  writeFileSync(join(target, 'package.json'), JSON.stringify({
47
47
  name: `${name}-template`,
48
48
  private: true,
@@ -62,7 +62,7 @@ A Port60 site template. Start with \`npm install\`, then:
62
62
 
63
63
  - \`npm run dev\`, live preview at http://localhost:4400
64
64
  - \`npm run validate\`, conformance against the platform contract
65
- - \`npm run package\`, the uploadable zip
65
+ - \`npm run package\`, the uploadable zip in \`dist/\` (recreated on every run)
66
66
 
67
67
  **Working with an AI agent?** Point it at this directory, \`AGENTS.md\` (and \`CLAUDE.md\`)
68
68
  brief it on the contract, the rules and the validate loop.
@@ -1,4 +1,4 @@
1
- import { writeFileSync } from 'node:fs';
1
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { resolve, join } from 'node:path';
3
3
  import { validateArtifact } from '../vendor/validator/validate.mjs';
4
4
  import { loadArtifactDir } from '../lib/artifactFiles.mjs';
@@ -7,7 +7,9 @@ import { buildZip } from '../lib/zip.mjs';
7
7
  /**
8
8
  * `package <dir>`, validate first (the platform will run the identical checks, so failing here
9
9
  * saves the round trip), then zip EXACTLY the contract-shaped file set into
10
- * `<name>-<version>.zip`, the artifact the studio's upload lane accepts as-is.
10
+ * `dist/<name>-<version>.zip`, the artifact the studio's upload lane accepts as-is. `dist/` is
11
+ * the build output and nothing else: recreated on every run, so a stale archive from a previous
12
+ * version can never sit beside the fresh one, and gitignored by the scaffold.
11
13
  */
12
14
  export async function packageCmd(args) {
13
15
  const dir = resolve(args._[0] ?? '.');
@@ -18,7 +20,10 @@ export async function packageCmd(args) {
18
20
  for (const e of errors) console.error(` - ${e}`);
19
21
  process.exit(1);
20
22
  }
21
- const out = join(dir, `${manifest.name}-${manifest.version}.zip`);
23
+ const outDir = join(dir, 'dist');
24
+ rmSync(outDir, { recursive: true, force: true });
25
+ mkdirSync(outDir);
26
+ const out = join(outDir, `${manifest.name}-${manifest.version}.zip`);
22
27
  writeFileSync(out, buildZip(Object.entries(files).map(([path, content]) => ({ path, content }))));
23
28
  console.log(`✓ ${out}`);
24
29
  console.log(' Upload it from your studio (tenant admin → Studio → your template → Versions).');
@@ -0,0 +1,180 @@
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, apiBase, loadCredentials, 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
+ * `publish --ci` (stream B, docs/developer-cli-and-ci-publishing.md): the same validate and
15
+ * package steps, but inside GitHub Actions there is no person to sign in. The workflow's own
16
+ * OIDC token, minted by GitHub for the platform's audience, is the credential; the platform
17
+ * verifies GitHub's signature and the repository trust the developer registered in the studio.
18
+ * CI can add versions to a trusted template, never create one, and review still decides.
19
+ * Auto-detected inside Actions when this machine holds no stored login.
20
+ */
21
+ export async function publish(args) {
22
+ // The CLI's parser reads `--flag value` greedily, so `publish --ci ./theme` would swallow the
23
+ // directory as the flag's value. Both flags here are booleans: hand a swallowed path back.
24
+ for (const flag of ['ci', 'no-submit']) {
25
+ if (typeof args[flag] === 'string') {
26
+ args._.unshift(args[flag]);
27
+ args[flag] = true;
28
+ }
29
+ }
30
+ const ci = args.ci === true || (process.env.GITHUB_ACTIONS === 'true' && !loadCredentials());
31
+ const creds = ci ? null : requireCredentials();
32
+ if (!ci && !creds.workspace?.tenantId) {
33
+ console.error('✗ No studio workspace on record. Run: p60-template-kit login');
34
+ process.exit(1);
35
+ }
36
+ const dir = resolve(args._[0] ?? '.');
37
+ const files = loadArtifactDir(dir);
38
+ const { errors, manifest } = await validateArtifact(files);
39
+ if (errors.length > 0) {
40
+ console.error(`✗ not publishing, ${errors.length} validation error${errors.length === 1 ? '' : 's'}:`);
41
+ for (const e of errors) console.error(` - ${e}`);
42
+ process.exit(1);
43
+ }
44
+ const zip = buildZip(Object.entries(files).map(([path, content]) => ({ path, content })));
45
+ console.log(`✓ ${manifest.name} ${manifest.version} validated, uploading${ci ? ' from CI' : ''}…`);
46
+
47
+ if (ci) {
48
+ await publishFromCi(args, zip);
49
+ return;
50
+ }
51
+
52
+ const token = await accessToken(creds);
53
+ const studio = `/api/admin/studio/tenants/${creds.workspace.tenantId}/studio`;
54
+ const res = await api(creds, token, `${studio}/uploads`, {
55
+ method: 'POST',
56
+ headers: { 'content-type': 'application/zip' },
57
+ body: zip
58
+ });
59
+ if (!res.ok) {
60
+ console.error(`✗ Upload refused (${res.status}): ${await errorMessage(res)}`);
61
+ if (res.status === 403) {
62
+ // The CLI holds the developer's OWN token; the server's permission model decides, exactly
63
+ // as in the studio UI. A role below the studio's minimum publishes nowhere.
64
+ console.error(' This account doesn\'t have permission to publish in that workspace.');
65
+ console.error(' Sign in with an account that administers the developer studio.');
66
+ }
67
+ process.exit(1);
68
+ }
69
+ const outcome = await res.json();
70
+ const upload = outcome.upload ?? {};
71
+ if (outcome.templateCreated) {
72
+ console.log(`✓ ${outcome.templateLabel} created from its manifest.`);
73
+ }
74
+ if (!printUpload(upload)) process.exit(1);
75
+
76
+ if (args['no-submit']) {
77
+ console.log(' Left unsubmitted (--no-submit). Submit it for review from your studio.');
78
+ return;
79
+ }
80
+
81
+ // Submission needs the version row's id; the detail read supplies it.
82
+ const detailRes = await api(creds, token, `${studio}/templates/${outcome.templateId}`);
83
+ if (!detailRes.ok) {
84
+ console.error(`⚠ Uploaded, but the submit step could not read the template (${detailRes.status}). Submit from your studio.`);
85
+ process.exit(1);
86
+ }
87
+ const detail = await detailRes.json();
88
+ const row = (detail.versions ?? []).find((v) => v.version === upload.version && v.status === 'VALIDATED');
89
+ if (!row) {
90
+ console.error('⚠ Uploaded, but the new version was not found in a submittable state. Submit from your studio.');
91
+ process.exit(1);
92
+ }
93
+ const submitRes = await api(creds, token, `${studio}/templates/${outcome.templateId}/versions/${row.id}/submit`, {
94
+ method: 'POST'
95
+ });
96
+ if (!submitRes.ok) {
97
+ console.error(`⚠ Uploaded, but submitting for review failed (${submitRes.status}). Submit from your studio.`);
98
+ process.exit(1);
99
+ }
100
+ console.log('✓ Submitted for review. You\'ll get an email when a reviewer decides.');
101
+ }
102
+
103
+ /** The audience the platform verifies; GitHub mints the token for exactly this value. */
104
+ export const CI_AUDIENCE = () => process.env.P60_CI_AUDIENCE || 'port60-studio-ci';
105
+
106
+ async function publishFromCi(args, zip) {
107
+ const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
108
+ const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
109
+ if (!requestUrl || !requestToken) {
110
+ console.error('✗ publish --ci runs inside GitHub Actions, and the job needs the id-token permission:');
111
+ console.error(' permissions:');
112
+ console.error(' id-token: write');
113
+ console.error(' contents: read');
114
+ process.exit(1);
115
+ }
116
+ const oidc = await fetchActionsToken(requestUrl, requestToken, CI_AUDIENCE());
117
+
118
+ const submit = !args['no-submit'];
119
+ const res = await fetch(`${apiBase()}/api/public/studio/ci/publish?submit=${submit}`, {
120
+ method: 'POST',
121
+ headers: { authorization: `Bearer ${oidc}`, 'content-type': 'application/zip' },
122
+ body: zip
123
+ });
124
+ if (!res.ok) {
125
+ console.error(`✗ Publish refused (${res.status}): ${await errorMessage(res)}`);
126
+ if (res.status === 403) {
127
+ console.error(' Trust this repository under Automate publishing on the template in your studio,');
128
+ console.error(' and check the workflow runs from a ref the trust allows (refs/tags/v* by default).');
129
+ }
130
+ process.exit(1);
131
+ }
132
+ const result = await res.json();
133
+ console.log(`✓ ${result.templateName} accepted this run from ${result.repository} (${result.ref}).`);
134
+ if (!printUpload(result.upload ?? {})) process.exit(1);
135
+ if (result.submitted) {
136
+ console.log('✓ Submitted for review. The developer is emailed when a reviewer decides.');
137
+ } else if (!submit) {
138
+ console.log(' Left unsubmitted (--no-submit). Submit it for review from the studio.');
139
+ } else {
140
+ console.error(`⚠ Uploaded, but not submitted: ${result.submitDetail ?? 'submit from the studio.'}`);
141
+ process.exit(1);
142
+ }
143
+ }
144
+
145
+ /** GitHub's per-job token endpoint: the request URL already carries its query string. */
146
+ async function fetchActionsToken(requestUrl, requestToken, audience) {
147
+ const url = `${requestUrl}${requestUrl.includes('?') ? '&' : '?'}audience=${encodeURIComponent(audience)}`;
148
+ const res = await fetch(url, { headers: { authorization: `Bearer ${requestToken}`, accept: 'application/json' } });
149
+ if (!res.ok) {
150
+ console.error(`✗ GitHub did not issue an OIDC token (${res.status}). Does the job have id-token: write?`);
151
+ process.exit(1);
152
+ }
153
+ const body = await res.json();
154
+ if (!body.value) {
155
+ console.error('✗ GitHub returned no token value.');
156
+ process.exit(1);
157
+ }
158
+ return body.value;
159
+ }
160
+
161
+ function printUpload(upload) {
162
+ if (!upload.accepted) {
163
+ console.error('✗ The platform rejected the version:');
164
+ for (const e of upload.errors ?? []) console.error(` - ${e}`);
165
+ return false;
166
+ }
167
+ for (const w of upload.warnings ?? []) console.log(` ⚠ ${w}`);
168
+ console.log(`✓ Version ${upload.version} uploaded and validated.`);
169
+ return true;
170
+ }
171
+
172
+ async function errorMessage(res) {
173
+ const text = await res.text();
174
+ try {
175
+ const body = JSON.parse(text);
176
+ return body.message ?? body.detail ?? text;
177
+ } catch {
178
+ return text;
179
+ }
180
+ }
@@ -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
+ }
@@ -14,7 +14,7 @@
14
14
  { "name": "--gold", "description": "Highlight tint (marks, ratings)." },
15
15
  { "name": "--marker", "description": "Impact-map pin colour (the live-map markers are a zoom-scaling circle layer that take this colour). Defaults to --accent." },
16
16
  { "name": "--map-mode", "description": "Impact-map basemap colour mode read by the map island: auto (derive the map's paper/ink from the theme), light, dark, or muted. Default auto." },
17
- { "name": "--map-projection", "description": "Impact-map projection: flat (default) or globe — the planet as a rotatable sphere with atmosphere." },
17
+ { "name": "--map-projection", "description": "Impact-map projection: flat (default) or globe, the planet as a rotatable sphere with atmosphere." },
18
18
  { "name": "--line", "description": "Hairline borders." },
19
19
  { "name": "--line-strong", "description": "Emphasised borders (selected, focus)." },
20
20
  { "name": "--danger", "description": "Errors and destructive affordances." },
@@ -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; }