@typeroll/mcp-server 0.45.81 → 0.45.83
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/AGENTS.md +7 -3
- package/README.md +14 -6
- package/dist/api-url.js +23 -0
- package/dist/bundled-content.js +10 -7
- package/dist/compact-tools.js +2 -2
- package/dist/extension-cli.js +37 -7
- package/dist/index.js +4 -0
- package/dist/server.js +2 -1
- package/dist/tools/access.js +22 -3
- package/dist/tools/extensions.js +3 -3
- package/dist/tools/settings.js +3 -1
- package/dist/version.js +1 -1
- package/dist/workspace.js +29 -6
- package/package.json +1 -1
- package/skills/tr-edit-site.md +5 -1
- package/workspace-templates/pages/README.md +40 -0
- package/workspace-templates/qa/checklist.md +7 -0
- package/workspace-templates/scripts/README.md +9 -1
- package/workspace-templates/scripts/typeroll/page_notes.py +144 -0
- package/workspace-templates/scripts/typeroll/trlib.py +26 -2
- package/workspace-templates/workflows/migration.md +7 -0
- package/workspace-templates/workflows/page-notes.md +20 -0
package/dist/compact-tools.js
CHANGED
|
@@ -17,7 +17,7 @@ export function compactTools(catalog) {
|
|
|
17
17
|
}),
|
|
18
18
|
local('describe_tool', 'Read one tool’s complete description, effect and JSON input schema before calling it.', { name: z.string().max(100) }, async (args) => {
|
|
19
19
|
const tool = byName.get(String(args.name));
|
|
20
|
-
return tool ? ok({ name: tool.name, effect: tool.effect, description: tool.description, inputSchema: z.toJSONSchema(z.object(tool.schema).
|
|
20
|
+
return tool ? ok({ name: tool.name, effect: tool.effect, description: tool.description, inputSchema: z.toJSONSchema(z.object(tool.schema).strict(), { target: 'draft-7', io: 'input' }) }) : fail(new Error('Unknown tool. Use search_tools.'));
|
|
21
21
|
}),
|
|
22
22
|
...['read', 'write', 'admin'].map(effect => ({
|
|
23
23
|
name: `call_${effect}_tool`, effect,
|
|
@@ -28,7 +28,7 @@ export function compactTools(catalog) {
|
|
|
28
28
|
if (!tool || tool.effect !== effect)
|
|
29
29
|
return fail(new Error(`This endpoint accepts only ${effect} tools. Use describe_tool to check the required effect.`));
|
|
30
30
|
try {
|
|
31
|
-
return await tool.invoke(z.object(tool.schema).parse(args.arguments));
|
|
31
|
+
return await tool.invoke(z.object(tool.schema).strict().parse(args.arguments));
|
|
32
32
|
}
|
|
33
33
|
catch (error) {
|
|
34
34
|
return fail(error);
|
package/dist/extension-cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { apiUrlProblem } from './api-url.js';
|
|
2
3
|
function option(args, name) {
|
|
3
4
|
const index = args.indexOf(name);
|
|
4
5
|
return index >= 0 ? args[index + 1] : undefined;
|
|
@@ -83,11 +84,32 @@ export function extensionMetadataUpdate(manifest, registered, manifestOrigins) {
|
|
|
83
84
|
}
|
|
84
85
|
return update;
|
|
85
86
|
}
|
|
87
|
+
/** One id segment, by the same rule the portal applies to allowed_site_ids. */
|
|
88
|
+
function isIdSegment(value) {
|
|
89
|
+
return value.length >= 1 && value.length <= 128 && value !== '.' && value !== '..' && !/[/\\\s\x00-\x1f\x7f]/.test(value);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Parse `install --site`. `org/site` names a site of any organization and is
|
|
93
|
+
* written to allowed_site_ids in that form; a bare site id only ever matches
|
|
94
|
+
* a site of the developer organization itself.
|
|
95
|
+
*/
|
|
96
|
+
export function parseInstallSite(value) {
|
|
97
|
+
const parts = value.split('/');
|
|
98
|
+
if (parts.length === 1 && isIdSegment(value))
|
|
99
|
+
return { siteId: value, ownerOrgId: null, allowEntry: value };
|
|
100
|
+
if (parts.length === 2 && isIdSegment(parts[0]) && isIdSegment(parts[1])) {
|
|
101
|
+
return { siteId: parts[1], ownerOrgId: parts[0], allowEntry: value };
|
|
102
|
+
}
|
|
103
|
+
throw new Error('--site must be <site-id> or <organization-id>/<site-id>');
|
|
104
|
+
}
|
|
86
105
|
async function api(path, init = {}, allowed = []) {
|
|
87
106
|
const baseUrl = process.env.TYPEROLL_API_URL?.trim().replace(/\/$/, '');
|
|
88
107
|
const apiKey = process.env.TYPEROLL_API_KEY?.trim();
|
|
89
108
|
if (!baseUrl || !apiKey)
|
|
90
109
|
throw new Error('TYPEROLL_API_URL and TYPEROLL_API_KEY are required');
|
|
110
|
+
const apiUrlError = apiUrlProblem(baseUrl);
|
|
111
|
+
if (apiUrlError)
|
|
112
|
+
throw new Error(apiUrlError);
|
|
91
113
|
const headers = new Headers(init.headers);
|
|
92
114
|
headers.set('Authorization', `Bearer ${apiKey}`);
|
|
93
115
|
if (init.body)
|
|
@@ -102,7 +124,7 @@ function help() {
|
|
|
102
124
|
console.error('Usage:');
|
|
103
125
|
console.error(' typeroll extension validate [manifest]');
|
|
104
126
|
console.error(' typeroll extension push --draft [--manifest path]');
|
|
105
|
-
console.error(' typeroll extension install --site <site-id> [--manifest path] [--config config.json]');
|
|
127
|
+
console.error(' typeroll extension install --site <site-id | organization-id/site-id> [--manifest path] [--config config.json]');
|
|
106
128
|
console.error(' typeroll extension configure --site <site-id> --installation <installation-id> --config config.json [--no-deploy]');
|
|
107
129
|
console.error(' typeroll extension promote <version> [--manifest path]');
|
|
108
130
|
}
|
|
@@ -193,24 +215,32 @@ export async function runExtensionCli(args) {
|
|
|
193
215
|
return 0;
|
|
194
216
|
}
|
|
195
217
|
if (command === 'install') {
|
|
196
|
-
const
|
|
197
|
-
if (!
|
|
198
|
-
throw new Error('install requires --site <site-id>');
|
|
218
|
+
const siteOption = option(args, '--site');
|
|
219
|
+
if (!siteOption)
|
|
220
|
+
throw new Error('install requires --site <site-id> or --site <organization-id>/<site-id>');
|
|
221
|
+
const site = parseInstallSite(siteOption);
|
|
199
222
|
const configPath = option(args, '--config');
|
|
200
223
|
const config = configPath ? await readJson(configPath) : {};
|
|
201
224
|
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
|
|
202
225
|
if (manifest.distribution === 'private') {
|
|
226
|
+
if (!site.ownerOrgId) {
|
|
227
|
+
console.error(`Note: allowed_site_ids entry "${site.allowEntry}" only allows a site of your developer organization. `
|
|
228
|
+
+ 'To allow a site of another organization, pass --site <organization-id>/<site-id>.');
|
|
229
|
+
}
|
|
203
230
|
const current = await api(`/api/developer/extensions/${encodeURIComponent(extensionId)}`);
|
|
204
231
|
const registered = current.data.extension;
|
|
205
232
|
const allowed = Array.isArray(registered?.allowed_site_ids) ? registered.allowed_site_ids.map(String) : [];
|
|
206
|
-
if (!allowed.includes(
|
|
233
|
+
if (!allowed.includes(site.allowEntry)) {
|
|
207
234
|
await api(`/api/developer/extensions/${encodeURIComponent(extensionId)}`, {
|
|
208
|
-
method: 'PATCH', body: JSON.stringify({ allowed_site_ids: [...allowed,
|
|
235
|
+
method: 'PATCH', body: JSON.stringify({ allowed_site_ids: [...allowed, site.allowEntry] }),
|
|
209
236
|
});
|
|
210
237
|
}
|
|
211
238
|
}
|
|
212
|
-
|
|
239
|
+
// With `org/site`, declare the owner so the server refuses to resolve the
|
|
240
|
+
// id to a different organization's site of the same name.
|
|
241
|
+
const installed = await api(`/api/v1/sites/${encodeURIComponent(site.siteId)}/extensions`, {
|
|
213
242
|
method: 'POST',
|
|
243
|
+
headers: site.ownerOrgId ? { 'Typeroll-Organization': site.ownerOrgId } : undefined,
|
|
214
244
|
body: JSON.stringify({
|
|
215
245
|
extension_id: extensionId,
|
|
216
246
|
version: manifest.version,
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { resolveSiteId } from './resolve-site-id.js';
|
|
|
20
20
|
import { buildServer } from './server.js';
|
|
21
21
|
import { VERSION } from './version.js';
|
|
22
22
|
import { runExtensionCli } from './extension-cli.js';
|
|
23
|
+
import { apiUrlProblem } from './api-url.js';
|
|
23
24
|
import { doctor, readWorkspace, workspaceClient, verifyWorkspaceBinding } from './workspace.js';
|
|
24
25
|
function bail(message) {
|
|
25
26
|
console.error(`typeroll-mcp: ${message}`);
|
|
@@ -85,6 +86,9 @@ async function main() {
|
|
|
85
86
|
const apiKey = process.env.TYPEROLL_API_KEY?.trim();
|
|
86
87
|
if (!apiUrl)
|
|
87
88
|
bail('TYPEROLL_API_URL is not set. Point it at your Typeroll portal (e.g. https://app.typeroll.com).');
|
|
89
|
+
const apiUrlError = apiUrlProblem(apiUrl);
|
|
90
|
+
if (apiUrlError)
|
|
91
|
+
bail(apiUrlError);
|
|
88
92
|
if (!apiKey)
|
|
89
93
|
bail('TYPEROLL_API_KEY is not set. Create a key in /app/sites/{siteId}/settings/api-keys and copy it once.');
|
|
90
94
|
if (!apiKey.startsWith('typeroll_live_')) {
|
package/dist/server.js
CHANGED
|
@@ -97,6 +97,7 @@ function effectFor(name) {
|
|
|
97
97
|
|| name === 'revoke_site_share'
|
|
98
98
|
|| name === 'revoke_organization_api_key'
|
|
99
99
|
|| name === 'create_organization_invite'
|
|
100
|
+
|| name === 'revoke_organization_invite'
|
|
100
101
|
|| name === 'set_organization_ai_key'
|
|
101
102
|
|| name === 'remove_organization_ai_key'
|
|
102
103
|
|| name === 'set_organization_ai_site_access'
|
|
@@ -327,7 +328,7 @@ export function buildServer(options) {
|
|
|
327
328
|
}
|
|
328
329
|
for (const tool of options.toolMode === 'compact' ? compactTools(catalog) : catalog) {
|
|
329
330
|
register(tool.name, {
|
|
330
|
-
description: tool.description, inputSchema: z.object(tool.schema).
|
|
331
|
+
description: tool.description, inputSchema: z.object(tool.schema).strict(),
|
|
331
332
|
annotations: { readOnlyHint: tool.effect === 'read', destructiveHint: tool.effect !== 'read', openWorldHint: true },
|
|
332
333
|
}, tool.invoke);
|
|
333
334
|
}
|
package/dist/tools/access.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { ok, withErrorBoundary } from './helpers.js';
|
|
8
8
|
const keyId = z.string().regex(/^[a-f0-9]{12}$/).describe('Key id (the 12-hex prefix) from list_api_keys / list_organization_api_keys.');
|
|
9
|
+
const inviteId = z.string().regex(/^[A-Za-z0-9_-]{16,64}$/).describe('Invite id from create_organization_invite or list_organization_invites.');
|
|
9
10
|
export const accessTools = [
|
|
10
11
|
{
|
|
11
12
|
name: 'list_api_keys',
|
|
12
|
-
description: 'List this Site\'s site-scoped API keys: id (prefix), name, created_at/created_by, last_used_at/last_used_ip and revoked_at. Never returns secrets or hashes.
|
|
13
|
+
description: 'List this Site\'s site-scoped API keys: id (prefix), name, created_at/created_by, last_used_at/last_used_ip and revoked_at. Never returns secrets or hashes. Requires admin on the Site, as in Site settings → API keys.',
|
|
13
14
|
inputSchema: {},
|
|
14
15
|
handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'api-keys'))),
|
|
15
16
|
},
|
|
@@ -69,10 +70,28 @@ export const accessTools = [
|
|
|
69
70
|
{
|
|
70
71
|
name: 'create_organization_invite',
|
|
71
72
|
noSite: true,
|
|
72
|
-
description: 'Create a signed invite link to the Organization (organization API key required). The invited person opens invite_url, signs in and joins as an editor
|
|
73
|
-
inputSchema: {
|
|
73
|
+
description: 'Create a signed invite link to the Organization (organization API key required). The invited person opens invite_url, signs in and joins as an editor. The link works until expires_at (ttl_days 1–30, default 7) for max_uses people (1–50, default 1: single use); with email set, only a person signed in with that address can redeem it. Returns the invite id; list_organization_invites shows its state and revoke_organization_invite (or DELETE /api/v1/organization/invites/{id}) stops the link immediately. Send the link only to the person the user named.',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
ttl_days: z.number().int().min(1).max(30).optional(),
|
|
76
|
+
max_uses: z.number().int().min(1).max(50).optional().describe('How many people may redeem the link. Default 1 (single use).'),
|
|
77
|
+
email: z.string().email().max(320).optional().describe('Bind the invite to this email address: only a person signed in with it can redeem the link.'),
|
|
78
|
+
},
|
|
74
79
|
handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('organization/invites', args))),
|
|
75
80
|
},
|
|
81
|
+
{
|
|
82
|
+
name: 'list_organization_invites',
|
|
83
|
+
noSite: true,
|
|
84
|
+
description: 'List the Organization\'s invites, newest first, without their links: id, state (active | used | expired | revoked), role, email (when bound), created_by, created_at, expires_at, max_uses, used_count and revoked_at. Requires an organization API key; site-scoped keys get 403.',
|
|
85
|
+
inputSchema: {},
|
|
86
|
+
handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootGet('organization/invites'))),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'revoke_organization_invite',
|
|
90
|
+
noSite: true,
|
|
91
|
+
description: 'Revoke an Organization invite by id (from create_organization_invite or list_organization_invites). Its link stops working immediately; people who already joined keep their membership. Requires an organization API key.',
|
|
92
|
+
inputSchema: { invite_id: inviteId },
|
|
93
|
+
handler: withErrorBoundary(async (args, { client }) => ok(await client.rootDelete(`organization/invites/${encodeURIComponent(args.invite_id)}`))),
|
|
94
|
+
},
|
|
76
95
|
{
|
|
77
96
|
name: 'read_organization_ai_settings',
|
|
78
97
|
noSite: true,
|
package/dist/tools/extensions.js
CHANGED
|
@@ -148,15 +148,15 @@ export const developerExtensionTools = [
|
|
|
148
148
|
},
|
|
149
149
|
{
|
|
150
150
|
name: 'update_developer_extension', noSite: true,
|
|
151
|
-
description: 'Update an Extension\'s name, status (active/suspended), distribution (only while every release is a draft), trusted origins or private allow-lists. Requires an organization API key.',
|
|
151
|
+
description: 'Update an Extension\'s name, status (active/suspended), distribution (only while every release is a draft), trusted origins or private allow-lists. Each list replaces the saved one: allowed_org_ids allows every Site of those Organizations; allowed_site_ids entries are "<organization-id>/<site-id>" for a Site of another Organization, while a bare site id only matches a Site of your own developer Organization. Requires an organization API key.',
|
|
152
152
|
inputSchema: {
|
|
153
153
|
extension_id: z.string().min(1),
|
|
154
154
|
name: z.string().optional(),
|
|
155
155
|
status: z.enum(['active', 'suspended']).optional(),
|
|
156
156
|
distribution: z.enum(['private', 'unlisted', 'public']).optional(),
|
|
157
157
|
trusted_origins: z.array(z.string()).optional(),
|
|
158
|
-
allowed_org_ids: z.array(z.string()).optional(),
|
|
159
|
-
allowed_site_ids: z.array(z.string()).optional(),
|
|
158
|
+
allowed_org_ids: z.array(z.string()).optional().describe('Organization ids whose every Site may install this private Extension. Replaces the saved list.'),
|
|
159
|
+
allowed_site_ids: z.array(z.string().max(257)).max(500).optional().describe('Sites that may install this private Extension, replacing the saved list: "<organization-id>/<site-id>" for another Organization\'s Site, or a bare site id for a Site of your own developer Organization.'),
|
|
160
160
|
},
|
|
161
161
|
handler: withErrorBoundary(async (args, { client }) => {
|
|
162
162
|
const { extension_id, ...patch } = args;
|
package/dist/tools/settings.js
CHANGED
|
@@ -40,7 +40,7 @@ export const settingsTools = [
|
|
|
40
40
|
{
|
|
41
41
|
name: 'update_site_settings',
|
|
42
42
|
description: 'Patch site settings, including responsive breakpoints for block layouts and visibility. Accepts everything the portal Settings form does. Requires admin permission on the site, as in the portal. Only the fields you pass change. ' +
|
|
43
|
-
'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" key. ' +
|
|
43
|
+
'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" or "patch" key. ' +
|
|
44
44
|
'Example: {"site_name": "Acme", "colors": {"primary": "#ff0"}} not {"settings": {...}}. ' +
|
|
45
45
|
'Nested objects (colors, fonts, contact, social) are shallow-merged into the existing value. ' +
|
|
46
46
|
'Unknown top-level keys return a 400 error listing the valid fields. ' +
|
|
@@ -118,6 +118,8 @@ export const settingsTools = [
|
|
|
118
118
|
},
|
|
119
119
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
120
120
|
const { version, ...body } = args;
|
|
121
|
+
if (!Object.keys(body).some(key => body[key] !== undefined))
|
|
122
|
+
throw new Error('No settings supplied. Pass at least one setting at the top level, for example {tagline: "New tagline"}; do not wrap fields in patch or settings.');
|
|
121
123
|
const res = await client.patch(siteId, 'settings', body, v(version));
|
|
122
124
|
return ok(res);
|
|
123
125
|
}),
|
package/dist/version.js
CHANGED
package/dist/workspace.js
CHANGED
|
@@ -5,12 +5,12 @@ import { z } from 'zod';
|
|
|
5
5
|
import { VERSION } from './version.js';
|
|
6
6
|
import { BUNDLED_WORKSPACE_TEMPLATES } from './bundled-content.js';
|
|
7
7
|
import { ApiError, TyperollClient } from './client.js';
|
|
8
|
+
import { isSecureApiUrl } from './api-url.js';
|
|
8
9
|
export const workspaceSchema = z.object({
|
|
9
10
|
schema_version: z.literal(1),
|
|
10
11
|
portal: z.string().url().refine(value => {
|
|
11
12
|
const url = new URL(value);
|
|
12
|
-
return !url.username && !url.password && !url.search && !url.hash && url.pathname === '/' &&
|
|
13
|
-
(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)));
|
|
13
|
+
return !url.username && !url.password && !url.search && !url.hash && url.pathname === '/' && isSecureApiUrl(url);
|
|
14
14
|
}, 'Use an HTTPS portal origin (HTTP is allowed only on loopback).'),
|
|
15
15
|
organization_id: z.string().min(1).nullable(), site_id: z.string().min(1).nullable(),
|
|
16
16
|
version: z.string().min(1), tool_mode: z.enum(['compact', 'full']),
|
|
@@ -51,6 +51,8 @@ Public documentation: https://typeroll.com/docs/llms.txt
|
|
|
51
51
|
## Edit and verify
|
|
52
52
|
|
|
53
53
|
- Read current Pages, Content types, templates, partials and relevant block schemas.
|
|
54
|
+
- Read pages/ notes before editing; follow workflows/page-notes.md for sources,
|
|
55
|
+
dated logs, external-link review and archiving. Keep internal notes out of public output.
|
|
54
56
|
- Use native editable blocks; document necessary exceptions and design decisions.
|
|
55
57
|
- Configure palette/font roles and design scales in site settings. Read workflows/design.md
|
|
56
58
|
before styling blocks; run lint_design_tokens and document hardcoded exceptions.
|
|
@@ -115,7 +117,7 @@ export const workspaceTemplates = {
|
|
|
115
117
|
'brief/content-model.md': '# Content model\n\n## Page types and routes\n\n## Fields and references\n\n## Shared templates and partials\n\n## Taxonomy and navigation\n\nUse the current CMS schema. This document describes decisions, not a second schema store.\n',
|
|
116
118
|
'brief/rules.md': "# Owner preferences and project rules\n\n## Confirmed rules\n\nRecord each rule, its scope, who approved it and the date. Separate confirmed rules from proposals.\n\n## Writing and factual accuracy\n\nVoice, terminology, prohibited claims, source requirements and legal copy that must not be rewritten.\n\n## Design and editing\n\nBrand constraints, accessibility, editable content, standard/custom blocks and documented HTML exceptions.\n\n## Operations and approval\n\nWho may approve content, publication, DNS changes and messages? Credentials stay outside this repository.\n\n## Exceptions and superseded rules\n\nLink decisions.md; do not silently override a confirmed rule. Project notes do not grant permissions beyond the user's authorization or account access.\n",
|
|
117
119
|
'brief/seo.md': "# SEO strategy\n\n## Business goals, audience, markets and languages\n\n## Search intent and topic coverage\n\nRecord research sources and dates. Distinguish evidence from assumptions; do not invent traffic or ranking forecasts.\n\n## Page roles and URL plan\n\nContent types, useful landing pages, taxonomy, internal links and routes to preserve or redirect. Avoid near-duplicate pages created only for keyword variants.\n\n## Indexing and canonical policy\n\nWhich pages should be indexable, public noindex, or private? Record deliberate canonical overrides and sitemap policy; actual values live in the CMS.\n\n## Editorial standards and structured data\n\nUnique useful copy, supported claims, descriptive titles and links, accessible image text, schema matching visible content. No fixed title length or word count is a hard SEO rule.\n\n## Measurement and review\n\nBaseline, analytics/consent decisions, measures of success and review dates. Never store credentials or private analytics exports here.\n",
|
|
118
|
-
'workflows/README.md': "# Project routines\n\nRead only the relevant routine and discover current tool schemas through MCP.\nProject files record intent; the CMS owns current content and configuration.\n\n| Task | Project context | On-demand recipe |\n| --- | --- | --- |\n| Guided start | workflows/interview.md, brief/interview.md | Ask one question at a time; resume confirmed answers |\n| New website | workflows/new-site.md, brief/site.md, brief/brand.md, brief/content-model.md | tr-new-site, tr-starter-design |\n| Migration | workflows/migration.md, brief/migration.md | tr-migration-evidence, then tr-migrate-wp or the matching source recipe |\n| Everyday editing | brief/rules.md and the relevant Page | tr-edit-site |\n| SEO | brief/seo.md and current site settings | tr-seo |\n| New reusable block | brief/brand.md and recorded editing needs | tr-custom-blocks |\n| Verification and publication | qa/checklist.md, decisions.md | Current preview/deploy tools and publishing readiness |\n\nAdd project-specific routines here with inputs, steps, acceptance evidence and approval boundaries. Do not copy the complete Typeroll manual or private app guides. Discover enabled app guidance with read_app_documentation. Load a recipe when needed so instructions stay current and context stays small.\n",
|
|
120
|
+
'workflows/README.md': "# Project routines\n\nRead only the relevant routine and discover current tool schemas through MCP.\nProject files record intent; the CMS owns current content and configuration.\n\n| Task | Project context | On-demand recipe |\n| --- | --- | --- |\n| Guided start | workflows/interview.md, brief/interview.md | Ask one question at a time; resume confirmed answers |\n| New website | workflows/new-site.md, brief/site.md, brief/brand.md, brief/content-model.md | tr-new-site, tr-starter-design |\n| Migration | workflows/migration.md, brief/migration.md | tr-migration-evidence, then tr-migrate-wp or the matching source recipe |\n| Everyday editing | brief/rules.md, pages/ notes and the relevant Page | tr-edit-site |\n| Page notes and review | workflows/page-notes.md, pages/README.md, pages/index.md | Read before edits; update logs afterward |\n| SEO | brief/seo.md and current site settings | tr-seo |\n| New reusable block | brief/brand.md and recorded editing needs | tr-custom-blocks |\n| Verification and publication | qa/checklist.md, decisions.md | Current preview/deploy tools and publishing readiness |\n\nAdd project-specific routines here with inputs, steps, acceptance evidence and approval boundaries. Do not copy the complete Typeroll manual or private app guides. Discover enabled app guidance with read_app_documentation. Load a recipe when needed so instructions stay current and context stays small.\n",
|
|
119
121
|
'decisions.md': '# Decisions\n\nRecord date, decision, reason, approved scope and alternatives when useful. For design-token exceptions include the source/block, fixed value, reason (e.g. white text on a photograph) and review criteria.\n',
|
|
120
122
|
'sources/README.md': '# Sources\n\nStore research references and copy proposals. Record source URLs and dates.\nPrivate extracts belong in ignored private/ or an approved external store.\nDo not treat local drafts as current CMS content or commit media libraries.\n',
|
|
121
123
|
};
|
|
@@ -184,11 +186,32 @@ export async function writeWorkspaceFiles(dir, templates, update) {
|
|
|
184
186
|
await fs.writeFile(lockFile, JSON.stringify({ schema_version: 1, generator_version: VERSION, files }, null, 2) + '\n');
|
|
185
187
|
return results;
|
|
186
188
|
}
|
|
189
|
+
/** The hosted Typeroll Cloud portal: the only origin trusted from typeroll.json alone. */
|
|
190
|
+
export const OFFICIAL_PORTAL_ORIGIN = 'https://app.typeroll.com';
|
|
191
|
+
/** Raised before any credential is sent when the committed portal is not confirmed by the environment. */
|
|
192
|
+
export class UntrustedPortalError extends Error {
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* typeroll.json is committed and can be changed by anyone who can open a pull
|
|
196
|
+
* request, so its portal alone never receives the injected API key. Another
|
|
197
|
+
* origin must be confirmed through the private TYPEROLL_API_URL setting.
|
|
198
|
+
*/
|
|
199
|
+
export function assertTrustedPortal(config, env) {
|
|
200
|
+
const portal = config.portal.replace(/\/$/, '');
|
|
201
|
+
const confirmed = env.TYPEROLL_API_URL?.trim().replace(/\/$/, '');
|
|
202
|
+
if (confirmed) {
|
|
203
|
+
if (confirmed !== portal)
|
|
204
|
+
throw new Error('TYPEROLL_API_URL conflicts with typeroll.json.');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (portal !== OFFICIAL_PORTAL_ORIGIN) {
|
|
208
|
+
throw new UntrustedPortalError(`typeroll.json names the portal ${portal}. The API key is sent only to ${OFFICIAL_PORTAL_ORIGIN} unless you confirm another portal: set TYPEROLL_API_URL=${portal} in your private client settings or environment.`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
187
211
|
export function workspaceClient(config, env, fetchImpl) {
|
|
188
212
|
if (!env.TYPEROLL_API_KEY)
|
|
189
213
|
throw new Error('Inject TYPEROLL_API_KEY through private client settings or the environment.');
|
|
190
|
-
|
|
191
|
-
throw new Error('TYPEROLL_API_URL conflicts with typeroll.json.');
|
|
214
|
+
assertTrustedPortal(config, env);
|
|
192
215
|
if (env.TYPEROLL_SITE_ID && env.TYPEROLL_SITE_ID !== config.site_id)
|
|
193
216
|
throw new Error('TYPEROLL_SITE_ID conflicts with typeroll.json.');
|
|
194
217
|
return new TyperollClient({ baseUrl: config.portal, apiKey: env.TYPEROLL_API_KEY, defaultVersion: config.version, fetchImpl });
|
|
@@ -227,7 +250,7 @@ export async function doctor(dir, offline, env = process.env, fetchImpl) {
|
|
|
227
250
|
checks.push({ check: 'writes_and_publishing', status: 'not_checked', message: 'Read-only doctor. No edits, media transfer, app activation or publication was attempted; check publishing/import readiness for those tasks.' });
|
|
228
251
|
}
|
|
229
252
|
catch (error) {
|
|
230
|
-
checks.push({ check: 'connection', status: 'required', message: error instanceof ApiError ? `Portal HTTP ${error.status}. Check credential scope, portal and Site/Version.` : 'Could not verify the connection. Check injected credentials, matching environment, network and Site/Version.' });
|
|
253
|
+
checks.push({ check: 'connection', status: 'required', message: error instanceof ApiError ? `Portal HTTP ${error.status}. Check credential scope, portal and Site/Version.` : error instanceof UntrustedPortalError ? error.message : 'Could not verify the connection. Check injected credentials, matching environment, network and Site/Version.' });
|
|
231
254
|
}
|
|
232
255
|
}
|
|
233
256
|
return { passed: !checks.some(check => check.status === 'required'), generator_version: VERSION, checks };
|
package/package.json
CHANGED
package/skills/tr-edit-site.md
CHANGED
|
@@ -19,7 +19,11 @@ description: Edit or create Typeroll pages with a person’s usual AI assistant.
|
|
|
19
19
|
content types, or `search_pages` for words in saved content. Ask the user to
|
|
20
20
|
choose when several pages fit their description. A different host requires
|
|
21
21
|
confirming a site switch and selecting a new context; never silently switch.
|
|
22
|
-
4.
|
|
22
|
+
4. When a project repository is available, read its pages/<page-id>.md note and
|
|
23
|
+
relevant decisions before editing. Follow workflows/page-notes.md: update the
|
|
24
|
+
dated log afterward, check external links during reviews, and archive notes
|
|
25
|
+
for confirmed removed Pages. Do not store private notes in public CMS fields.
|
|
26
|
+
Read the page and relevant Page template, block types and reusable sections.
|
|
23
27
|
Prefer existing native blocks and shared styles. Editors may create custom
|
|
24
28
|
block types and shared Page templates when needed. Read `tr-custom-blocks`
|
|
25
29
|
or `tr-page-template` only when that work is relevant. Before changing a
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Internal Page notes
|
|
2
|
+
|
|
3
|
+
Keep internal sources, decisions, comments and dated history in this repository,
|
|
4
|
+
not in public CMS fields. The CMS remains the source of current content; notes
|
|
5
|
+
are editorial context, never a second content copy. Keep this workspace private
|
|
6
|
+
if notes contain private information. Never put credentials or signed URLs here.
|
|
7
|
+
Do not include pages/ in static output, content imports or public source bundles.
|
|
8
|
+
|
|
9
|
+
One file per stable Page ID: pages/<encoded-page-id>.md. The helper percent-encodes
|
|
10
|
+
IDs that contain path characters. URLs and titles can change without renaming notes.
|
|
11
|
+
Use a separate workspace/Git branch for another Site or Version; the generated
|
|
12
|
+
facts are bound to portal, Organization, Site and Version and reject mismatches.
|
|
13
|
+
|
|
14
|
+
Each note has review, next_review (YYYY-MM or YYYY-MM-DD), last_checked and
|
|
15
|
+
responsible metadata, followed by Purpose, Sources (including date and whether an
|
|
16
|
+
owner statement), Publishing considerations, Open questions and Log. Use dated
|
|
17
|
+
change, decision, comment and check entries; keep the newest first. Site-wide
|
|
18
|
+
rules belong in decisions.md, referenced from the relevant notes.
|
|
19
|
+
|
|
20
|
+
Choose a review cadence for the project, for example quarterly, annual, event-led
|
|
21
|
+
or when source facts change. There is no default season or automatic review date.
|
|
22
|
+
Refreshing facts never changes last_checked or next_review. Set those only after
|
|
23
|
+
an actual review. index.md sorts scheduled reviews first, then unscheduled Pages.
|
|
24
|
+
|
|
25
|
+
The generated block contains saved CMS metadata and candidate links from the saved
|
|
26
|
+
body. It does not inspect template/partial links, JavaScript or final rendering;
|
|
27
|
+
query-bearing URLs are omitted to avoid copying credentials. Check the rendered
|
|
28
|
+
Page and every external link during review. Sources, historical import statistics
|
|
29
|
+
and their dates belong in the human sections, not invented by the script.
|
|
30
|
+
|
|
31
|
+
Run scripts/typeroll/page_notes.py --dry-run, inspect, then omit --dry-run to refresh.
|
|
32
|
+
--migration-inventory optionally includes recorded redirect sources. Run from the
|
|
33
|
+
workspace root with a privately injected TYPEROLL_API_KEY. No CMS writes occur.
|
|
34
|
+
Only the generated markers and index.md are refreshed; malformed markers fail
|
|
35
|
+
without overwriting notes. Reconcile existing notes manually before adopting the
|
|
36
|
+
new marker/scope format; do not replace an established project's editorial history.
|
|
37
|
+
|
|
38
|
+
Notes without a Page are reported, never deleted automatically. Confirm deletion
|
|
39
|
+
and any redirect, add a final log entry, then move the note to pages/archived/.
|
|
40
|
+
Do not archive just because an API call failed or access changed.
|
|
@@ -50,3 +50,10 @@ indexing have distinct effects but do not require repeated approval if covered.
|
|
|
50
50
|
updates, future-host candidates and explicit traffic approval.
|
|
51
51
|
- [ ] Inspect the frozen candidate's noindex/robots output, not only current CMS settings.
|
|
52
52
|
- [ ] Review owner facts and dates against the chosen preserve/renew text policy.
|
|
53
|
+
|
|
54
|
+
## Internal Page notes
|
|
55
|
+
|
|
56
|
+
Follow workflows/page-notes.md and pages/README.md. Create notes during migration,
|
|
57
|
+
read before edits, update afterward, check external links at each editorial review,
|
|
58
|
+
and archive notes for confirmed removed Pages under pages/archived/. Refreshing
|
|
59
|
+
generated facts never counts as a completed review. Keep notes out of public output.
|
|
@@ -13,7 +13,8 @@ npx playwright install chromium
|
|
|
13
13
|
Run the commands below from the **workspace root**. Inject TYPEROLL_API_KEY through
|
|
14
14
|
your private credential manager. Never put it in a file or command argument.
|
|
15
15
|
trlib.py reads portal, organization_id, site_id and version from typeroll.json,
|
|
16
|
-
|
|
16
|
+
sends the key only to https://app.typeroll.com or to a portal you confirm by
|
|
17
|
+
setting TYPEROLL_API_URL to the same origin privately, checks the connected Site/Organization, discovers the live tool schema, and
|
|
17
18
|
passes the Version where supported. It refuses unversioned operations on a
|
|
18
19
|
non-main binding; use the explicit interactive API workflow for organization or
|
|
19
20
|
shared media operations instead. It is a small hosted MCP helper for API keys,
|
|
@@ -109,3 +110,10 @@ uploading or changing CMS content. Missing CMYK profiles, transparency, multiple
|
|
|
109
110
|
frames and existing output files stop with an error. `--assume-srgb` is only for
|
|
110
111
|
reviewed unprofiled RGB/gray; `--source-profile FILE.icc` supplies the actual profile.
|
|
111
112
|
See workflows/source-media.md for archive verification and original preservation.
|
|
113
|
+
|
|
114
|
+
## Internal Page notes
|
|
115
|
+
|
|
116
|
+
`page_notes.py --dry-run` previews local note creation/fact refresh and index updates.
|
|
117
|
+
Omit --dry-run to write local files; --migration-inventory adds recorded redirect
|
|
118
|
+
sources. It never writes to the CMS, marks a Page reviewed or deletes notes. Read
|
|
119
|
+
pages/README.md and workflows/page-notes.md before first use.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Refresh repository-only Page facts; never edit CMS content or human notes.
|
|
2
|
+
|
|
3
|
+
Run from the workspace root. --dry-run previews local changes. Optional
|
|
4
|
+
--migration-inventory adds saved migration URL facts. Credentials use trlib.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
from datetime import date
|
|
8
|
+
import hashlib
|
|
9
|
+
from html.parser import HTMLParser
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
from urllib.parse import quote, urlsplit
|
|
14
|
+
import trlib
|
|
15
|
+
|
|
16
|
+
START = '<!-- generated:start (page_notes.py; do not edit by hand) -->'
|
|
17
|
+
END = '<!-- generated:end -->'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def filename(page_id):
|
|
21
|
+
if not isinstance(page_id, str) or not page_id or page_id.lower() in ('readme', 'index', 'archived', '.', '..'):
|
|
22
|
+
raise ValueError('Page ID is empty or conflicts with a reserved notes path')
|
|
23
|
+
return quote(page_id, safe='-_') + '.md'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cell(value):
|
|
27
|
+
return str(value if value is not None else '').replace('&', '&').replace('<', '<').replace('>', '>').replace('|', '|').replace('\n', ' ').replace('\r', ' ')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Links(HTMLParser):
|
|
31
|
+
def __init__(self):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.urls = set()
|
|
34
|
+
|
|
35
|
+
def handle_starttag(self, tag, attrs):
|
|
36
|
+
if tag == 'a':
|
|
37
|
+
self.urls.update(v for k, v in attrs if k == 'href' and v)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def body_links(page, blocks):
|
|
41
|
+
parser = Links()
|
|
42
|
+
parser.feed(page.get('html_content') or '')
|
|
43
|
+
|
|
44
|
+
def visit(value, key=''):
|
|
45
|
+
if isinstance(value, dict):
|
|
46
|
+
for k, v in value.items():
|
|
47
|
+
visit(v, k)
|
|
48
|
+
elif isinstance(value, list):
|
|
49
|
+
for v in value:
|
|
50
|
+
visit(v, key)
|
|
51
|
+
elif isinstance(value, str):
|
|
52
|
+
if key in ('href', 'link', 'url', 'button_url', 'link_url'):
|
|
53
|
+
parser.urls.add(value)
|
|
54
|
+
if '<a' in value.lower():
|
|
55
|
+
parser.feed(value)
|
|
56
|
+
for block in trlib.walk(blocks):
|
|
57
|
+
visit(block.get('data') or {})
|
|
58
|
+
# Do not persist signed preview links or query credentials in repository notes.
|
|
59
|
+
return sorted(u for u in parser.urls if not urlsplit(u).query and not urlsplit(u).username
|
|
60
|
+
and urlsplit(u).scheme in ('', 'http', 'https'))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def scope(config):
|
|
64
|
+
return hashlib.sha256(json.dumps([config[k] for k in ('portal', 'organization_id', 'site_id', 'version')]).encode()).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def refresh(root, config, pages, links, migration=(), dry_run=False):
|
|
68
|
+
"""Read all inputs before writing. Preserve every byte outside owned markers."""
|
|
69
|
+
out = root / 'pages'
|
|
70
|
+
if out.is_symlink():
|
|
71
|
+
raise ValueError('The notes directory must not be a symlink')
|
|
72
|
+
names = [filename(p['id']) for p in pages]
|
|
73
|
+
if len({n.lower() for n in names}) != len(names):
|
|
74
|
+
raise ValueError('Page IDs collide on a case-insensitive filesystem')
|
|
75
|
+
identity = '<!-- scope: ' + scope(config) + ' -->'
|
|
76
|
+
planned, rows = {}, []
|
|
77
|
+
for p, name in zip(pages, names):
|
|
78
|
+
file = out / name
|
|
79
|
+
if file.is_symlink():
|
|
80
|
+
raise ValueError('A note must not be a symlink')
|
|
81
|
+
text = file.read_text() if file.exists() else (
|
|
82
|
+
'---\nreview: unset\nnext_review: \nlast_checked: \nresponsible: \n---\n\n# ' + cell(p['title']) + '\n\n'
|
|
83
|
+
+ START + '\n' + identity + '\n' + END
|
|
84
|
+
+ '\n\n## Purpose\n\nTODO\n\n## Sources\n\nTODO\n\n## Publishing considerations\n\nTODO\n\n'
|
|
85
|
+
'## Open questions\n\n- Set a review cadence and next review date.\n\n## Log\n\n- '
|
|
86
|
+
+ date.today().isoformat() + ' · comment · Note created; editorial review has not been performed.\n')
|
|
87
|
+
if text.count(START) != 1 or text.count(END) != 1 or text.index(START) >= text.index(END):
|
|
88
|
+
raise ValueError('Missing or ambiguous generated markers in ' + name + '; reconcile manually')
|
|
89
|
+
old = text.split(START)[1].split(END)[0]
|
|
90
|
+
if identity not in old:
|
|
91
|
+
raise ValueError('Note scope differs from the selected Organization/Site/Version: ' + name)
|
|
92
|
+
facts = [START, identity, '', '| Fact | Value |', '| --- | --- |']
|
|
93
|
+
for label, value in [('Page ID', p['id']), ('URL', p.get('url')), ('Title', p['title']),
|
|
94
|
+
('Status', p.get('status')), ('Content type', p.get('content_type')),
|
|
95
|
+
('Template', p.get('template')), ('Last saved', p.get('date_updated'))]:
|
|
96
|
+
facts.append('| ' + label + ' | ' + cell(value) + ' |')
|
|
97
|
+
aliases = [m['path'] for m in migration if m.get('status') == 'redirected' and m.get('target', '').rstrip('/') == p.get('url', '').rstrip('/')]
|
|
98
|
+
if aliases:
|
|
99
|
+
facts.append('| Recorded old URLs | ' + cell(', '.join(sorted(aliases))) + ' |')
|
|
100
|
+
facts += ['', 'Saved body link candidates (not a complete rendered-link audit; query URLs omitted):', '']
|
|
101
|
+
facts += ['- ' + cell(u) for u in links.get(p['id'], [])] or ['- None found.']
|
|
102
|
+
facts += ['', END]
|
|
103
|
+
new = text[:text.index(START)] + '\n'.join(facts) + text[text.index(END) + len(END):]
|
|
104
|
+
if new != text:
|
|
105
|
+
planned[file] = new
|
|
106
|
+
elif not file.exists():
|
|
107
|
+
planned[file] = new
|
|
108
|
+
fm = dict(re.findall(r'^(review|next_review|last_checked|responsible):[ \t]*([^\n]*)', new.split('---', 2)[1], re.M)) if new.startswith('---\n') else {}
|
|
109
|
+
nxt = fm.get('next_review', '').strip()
|
|
110
|
+
if nxt and not re.fullmatch(r'\d{4}-\d{2}(?:-\d{2})?', nxt):
|
|
111
|
+
raise ValueError('Use YYYY-MM or YYYY-MM-DD for next_review in ' + name)
|
|
112
|
+
rows.append((nxt or '9999', name, '| ' + (nxt or 'Not scheduled') + ' | [' + cell(p['title']).replace('[', '[').replace(']', ']') + '](' + name + ') | ' + cell(fm.get('review', 'unset')) + ' | ' + cell(fm.get('last_checked', '')) + ' |'))
|
|
113
|
+
index = out / 'index.md'
|
|
114
|
+
if index.is_symlink():
|
|
115
|
+
raise ValueError('The notes index must not be a symlink')
|
|
116
|
+
index_text = '# Page notes index\n\nGenerated from local notes; dates are editorial decisions, not CMS save dates.\n\n| Next review | Page | Cadence | Last checked |\n| --- | --- | --- | --- |\n' + '\n'.join(r[2] for r in sorted(rows)) + '\n'
|
|
117
|
+
if not index.exists() or index.read_text() != index_text:
|
|
118
|
+
planned[index] = index_text
|
|
119
|
+
orphans = sorted(f.name for f in out.glob('*.md') if f.name not in {*names, 'README.md', 'index.md'})
|
|
120
|
+
if not dry_run:
|
|
121
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
for file, text in planned.items():
|
|
123
|
+
file.write_text(text)
|
|
124
|
+
return {'changed': [str(f.relative_to(root)) for f in planned], 'notes_without_page': orphans, 'dry_run': dry_run}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main():
|
|
128
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
129
|
+
parser.add_argument('--dry-run', action='store_true')
|
|
130
|
+
parser.add_argument('--migration-inventory', action='store_true')
|
|
131
|
+
args = parser.parse_args()
|
|
132
|
+
root = Path.cwd()
|
|
133
|
+
if not (root / 'typeroll.json').is_file():
|
|
134
|
+
parser.error('Run from the workspace root containing typeroll.json')
|
|
135
|
+
config = trlib.workspace()
|
|
136
|
+
# list_pages(full=True) reads saved content, unlike read_page's draft overlay.
|
|
137
|
+
pages = list(trlib.all_pages(full=True))
|
|
138
|
+
links = {p['id']: body_links(p, p.get('blocks') or []) for p in pages}
|
|
139
|
+
migration = list(trlib.all_migration_urls()) if args.migration_inventory else []
|
|
140
|
+
print(json.dumps(refresh(root, config, pages, links, migration, args.dry_run), indent=2))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == '__main__':
|
|
144
|
+
main()
|
|
@@ -31,6 +31,28 @@ def workspace():
|
|
|
31
31
|
raise ValueError('Run from a workspace containing typeroll.json')
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
OFFICIAL_PORTAL = 'https://app.typeroll.com'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def trusted_portal(config):
|
|
38
|
+
"""Return the portal origin that may receive the API key.
|
|
39
|
+
|
|
40
|
+
typeroll.json is committed, so its portal alone is trusted only when it is
|
|
41
|
+
the hosted portal. Another origin must be confirmed by TYPEROLL_API_URL.
|
|
42
|
+
"""
|
|
43
|
+
portal = config['portal'].rstrip('/')
|
|
44
|
+
confirmed = (os.environ.get('TYPEROLL_API_URL') or '').strip().rstrip('/')
|
|
45
|
+
if confirmed:
|
|
46
|
+
if confirmed != portal:
|
|
47
|
+
raise ValueError('TYPEROLL_API_URL conflicts with typeroll.json')
|
|
48
|
+
return portal
|
|
49
|
+
if portal != OFFICIAL_PORTAL:
|
|
50
|
+
raise ValueError(f'typeroll.json names the portal {portal}. The API key is sent only to '
|
|
51
|
+
f'{OFFICIAL_PORTAL} unless you confirm another portal: set '
|
|
52
|
+
f'TYPEROLL_API_URL={portal} in your private environment')
|
|
53
|
+
return portal
|
|
54
|
+
|
|
55
|
+
|
|
34
56
|
class Client:
|
|
35
57
|
def __init__(self, config=None):
|
|
36
58
|
self.config = config or workspace()
|
|
@@ -51,6 +73,8 @@ class Client:
|
|
|
51
73
|
key = os.environ.get('TYPEROLL_API_KEY')
|
|
52
74
|
if not key:
|
|
53
75
|
raise ValueError('Inject TYPEROLL_API_KEY privately before running')
|
|
76
|
+
if trusted_portal(self.config) + '/api/mcp?tools=full' != self.url:
|
|
77
|
+
raise ValueError('Workspace portal changed after the client was created')
|
|
54
78
|
headers = {'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json',
|
|
55
79
|
'Accept': 'application/json, text/event-stream'}
|
|
56
80
|
if self.session:
|
|
@@ -155,10 +179,10 @@ def walk(blocks):
|
|
|
155
179
|
yield from walk(slot)
|
|
156
180
|
|
|
157
181
|
|
|
158
|
-
def all_pages():
|
|
182
|
+
def all_pages(full=False):
|
|
159
183
|
cursor, seen = None, set()
|
|
160
184
|
while True:
|
|
161
|
-
args = {'limit': 200, **({'cursor': cursor} if cursor else {})}
|
|
185
|
+
args = {'limit': 200, **({'full': True} if full else {}), **({'cursor': cursor} if cursor else {})}
|
|
162
186
|
result = call('list_pages', args)
|
|
163
187
|
yield from result['pages']
|
|
164
188
|
cursor = result.get('next_cursor')
|
|
@@ -127,3 +127,10 @@ recurring-event wording only when a source supports recurrence. Flag uncertain
|
|
|
127
127
|
facts for review or keep new Pages in draft; missing evidence does not prove that
|
|
128
128
|
an event stopped. Link to the actual organiser when relevant. Upload approved
|
|
129
129
|
photos with SHA-256 verification and review focal points at mobile/desktop widths.
|
|
130
|
+
|
|
131
|
+
## Internal Page notes
|
|
132
|
+
|
|
133
|
+
Follow workflows/page-notes.md and pages/README.md. Create notes during migration,
|
|
134
|
+
read before edits, update afterward, check external links at each editorial review,
|
|
135
|
+
and archive notes for confirmed removed Pages under pages/archived/. Refreshing
|
|
136
|
+
generated facts never counts as a completed review. Keep notes out of public output.
|