@port60/template-kit 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -93,6 +93,8 @@ usage:
93
93
  p60-template-kit logout sign this machine out and revoke its token
94
94
  p60-template-kit publish [dir] [--no-submit] validate, package, upload to your studio and
95
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
96
98
 
97
99
  AI agents: the scaffold's AGENTS.md is your briefing; iterate with \`validate --json\`.
98
100
  Docs: https://developers.port60.com (agents: /llms-full.txt)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@port60/template-kit",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
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": {
@@ -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).');
@@ -2,18 +2,35 @@ import { resolve } from 'node:path';
2
2
  import { validateArtifact } from '../vendor/validator/validate.mjs';
3
3
  import { loadArtifactDir } from '../lib/artifactFiles.mjs';
4
4
  import { buildZip } from '../lib/zip.mjs';
5
- import { accessToken, api, requireCredentials } from '../lib/auth.mjs';
5
+ import { accessToken, api, apiBase, loadCredentials, requireCredentials } from '../lib/auth.mjs';
6
6
 
7
7
  /**
8
8
  * `publish [dir]` (developer CLI stream A): validate, package and upload in one breath, then
9
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
10
+ * the template and a claimed one gains a version; the server runs the identical validation
11
11
  * again, and a human reviews before anything can reach a charity's site. `--no-submit` stops at
12
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
+ * A trusted workflow does what a signed-in terminal does (a new name creates the template, an
19
+ * owned name gains a version); review still decides.
20
+ * Auto-detected inside Actions when this machine holds no stored login.
13
21
  */
14
22
  export async function publish(args) {
15
- const creds = requireCredentials();
16
- if (!creds.workspace?.tenantId) {
23
+ // The CLI's parser reads `--flag value` greedily, so `publish --ci ./theme` would swallow the
24
+ // directory as the flag's value. Both flags here are booleans: hand a swallowed path back.
25
+ for (const flag of ['ci', 'no-submit']) {
26
+ if (typeof args[flag] === 'string') {
27
+ args._.unshift(args[flag]);
28
+ args[flag] = true;
29
+ }
30
+ }
31
+ const ci = args.ci === true || (process.env.GITHUB_ACTIONS === 'true' && !loadCredentials());
32
+ const creds = ci ? null : requireCredentials();
33
+ if (!ci && !creds.workspace?.tenantId) {
17
34
  console.error('✗ No studio workspace on record. Run: p60-template-kit login');
18
35
  process.exit(1);
19
36
  }
@@ -26,7 +43,12 @@ export async function publish(args) {
26
43
  process.exit(1);
27
44
  }
28
45
  const zip = buildZip(Object.entries(files).map(([path, content]) => ({ path, content })));
29
- console.log(`✓ ${manifest.name} ${manifest.version} validated, uploading…`);
46
+ console.log(`✓ ${manifest.name} ${manifest.version} validated, uploading${ci ? ' from CI' : ''}…`);
47
+
48
+ if (ci) {
49
+ await publishFromCi(args, zip);
50
+ return;
51
+ }
30
52
 
31
53
  const token = await accessToken(creds);
32
54
  const studio = `/api/admin/studio/tenants/${creds.workspace.tenantId}/studio`;
@@ -36,15 +58,7 @@ export async function publish(args) {
36
58
  body: zip
37
59
  });
38
60
  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}`);
61
+ console.error(`✗ Upload refused (${res.status}): ${await errorMessage(res)}`);
48
62
  if (res.status === 403) {
49
63
  // The CLI holds the developer's OWN token; the server's permission model decides, exactly
50
64
  // as in the studio UI. A role below the studio's minimum publishes nowhere.
@@ -58,13 +72,7 @@ export async function publish(args) {
58
72
  if (outcome.templateCreated) {
59
73
  console.log(`✓ ${outcome.templateLabel} created from its manifest.`);
60
74
  }
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.`);
75
+ if (!printUpload(upload)) process.exit(1);
68
76
 
69
77
  if (args['no-submit']) {
70
78
  console.log(' Left unsubmitted (--no-submit). Submit it for review from your studio.');
@@ -92,3 +100,85 @@ export async function publish(args) {
92
100
  }
93
101
  console.log('✓ Submitted for review. You\'ll get an email when a reviewer decides.');
94
102
  }
103
+
104
+ /** The audience the platform verifies; GitHub mints the token for exactly this value. */
105
+ export const CI_AUDIENCE = () => process.env.P60_CI_AUDIENCE || 'port60-studio-ci';
106
+
107
+ async function publishFromCi(args, zip) {
108
+ const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
109
+ const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
110
+ if (!requestUrl || !requestToken) {
111
+ console.error('✗ publish --ci runs inside GitHub Actions, and the job needs the id-token permission:');
112
+ console.error(' permissions:');
113
+ console.error(' id-token: write');
114
+ console.error(' contents: read');
115
+ process.exit(1);
116
+ }
117
+ const oidc = await fetchActionsToken(requestUrl, requestToken, CI_AUDIENCE());
118
+
119
+ const submit = !args['no-submit'];
120
+ const res = await fetch(`${apiBase()}/api/public/studio/ci/publish?submit=${submit}`, {
121
+ method: 'POST',
122
+ headers: { authorization: `Bearer ${oidc}`, 'content-type': 'application/zip' },
123
+ body: zip
124
+ });
125
+ if (!res.ok) {
126
+ console.error(`✗ Publish refused (${res.status}): ${await errorMessage(res)}`);
127
+ if (res.status === 403) {
128
+ console.error(' Trust this repository under Automate publishing in your studio,');
129
+ console.error(' and check the workflow runs from a ref the trust allows (refs/tags/v* by default).');
130
+ }
131
+ process.exit(1);
132
+ }
133
+ const result = await res.json();
134
+ if (result.templateCreated) {
135
+ console.log(`✓ ${result.templateLabel ?? result.templateName} created from its manifest.`);
136
+ }
137
+ console.log(`✓ ${result.templateName} accepted this run from ${result.repository} (${result.ref}).`);
138
+ if (!printUpload(result.upload ?? {})) process.exit(1);
139
+ if (result.submitted) {
140
+ console.log('✓ Submitted for review. The developer is emailed when a reviewer decides.');
141
+ } else if (!submit) {
142
+ console.log(' Left unsubmitted (--no-submit). Submit it for review from the studio.');
143
+ } else {
144
+ console.error(`⚠ Uploaded, but not submitted: ${result.submitDetail ?? 'submit from the studio.'}`);
145
+ process.exit(1);
146
+ }
147
+ }
148
+
149
+ /** GitHub's per-job token endpoint: the request URL already carries its query string. */
150
+ async function fetchActionsToken(requestUrl, requestToken, audience) {
151
+ const url = `${requestUrl}${requestUrl.includes('?') ? '&' : '?'}audience=${encodeURIComponent(audience)}`;
152
+ const res = await fetch(url, { headers: { authorization: `Bearer ${requestToken}`, accept: 'application/json' } });
153
+ if (!res.ok) {
154
+ console.error(`✗ GitHub did not issue an OIDC token (${res.status}). Does the job have id-token: write?`);
155
+ process.exit(1);
156
+ }
157
+ const body = await res.json();
158
+ if (!body.value) {
159
+ console.error('✗ GitHub returned no token value.');
160
+ process.exit(1);
161
+ }
162
+ return body.value;
163
+ }
164
+
165
+ function printUpload(upload) {
166
+ if (!upload.accepted) {
167
+ console.error('✗ The platform rejected the version:');
168
+ for (const e of upload.errors ?? []) console.error(` - ${e}`);
169
+ return false;
170
+ }
171
+ for (const w of upload.warnings ?? []) console.log(` ⚠ ${w}`);
172
+ console.log(`✓ Version ${upload.version} uploaded and validated.`);
173
+ return true;
174
+ }
175
+
176
+ async function errorMessage(res) {
177
+ const text = await res.text();
178
+ try {
179
+ const body = JSON.parse(text);
180
+ return body.message ?? body.detail ?? text;
181
+ } catch {
182
+ return text;
183
+ }
184
+ }
@@ -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." },