create-ailk 0.28.0 → 0.29.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/README.md +124 -1
- package/dist/cli.js +92 -2
- package/dist/lib/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -0
- package/dist/lib/import-mdx.d.ts +59 -0
- package/dist/lib/import-mdx.js +452 -0
- package/dist/migrate-content.d.ts +45 -0
- package/dist/migrate-content.js +215 -0
- package/dist/seed-workspace.d.ts +71 -0
- package/dist/seed-workspace.js +242 -0
- package/dist/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/update.d.ts +32 -2
- package/dist/update.js +357 -44
- package/package.json +3 -3
- package/templates/apps/api/CLAUDE.md +1 -1
- package/templates/apps/mcp/CLAUDE.md +16 -12
- package/templates/apps/web/CLAUDE.md +11 -1
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +4 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/careers/page.tsx +23 -15
- package/templates/apps/web/app/[locale]/clients/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/faq/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/glossary/page.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +6 -0
- package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +1 -0
- package/templates/apps/web/lib/hub-children.tsx +38 -9
- package/templates/apps/web/messages/en.json +22 -2
- package/templates/tools/declared-env.ts +7 -0
- package/versions.json +48 -48
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create-ailk migrate-content [<dir>] [--apply] [--api-url <origin>]
|
|
3
|
+
* [--workspace <id>]` — import an existing site's MDX pages into its
|
|
4
|
+
* workspace (#10207).
|
|
5
|
+
*
|
|
6
|
+
* The same import as `seed-workspace` — `importMdx` over `<dir>/content` with
|
|
7
|
+
* the site's own installed adapter — for the site `seed-workspace` refuses: one
|
|
8
|
+
* with no `.ailk-site-manifest.json`, or a spoke, whose workspace lives on its
|
|
9
|
+
* hub (D1). No manifest and no topology is read; the operator names the API
|
|
10
|
+
* origin and the workspace, which is all the topology would decide.
|
|
11
|
+
*
|
|
12
|
+
* A dry run is the default (D3): the importer runs over `readOnlyFetch`, which
|
|
13
|
+
* passes each `GET` through and answers a `POST` or `PATCH` locally without
|
|
14
|
+
* sending it, so the plan — create · update · unchanged · skipped · failed — is
|
|
15
|
+
* decided by the importer itself against the live workspace, and no write can
|
|
16
|
+
* leave the process. `--apply` writes; a second `--apply` reports every page
|
|
17
|
+
* `unchanged`.
|
|
18
|
+
*
|
|
19
|
+
* Credentials follow `seed-workspace` (#10204 D2): the origin from
|
|
20
|
+
* `--api-url`, else `AILK_API_URL`; the workspace id from `--workspace`, else
|
|
21
|
+
* `AILK_WORKSPACE_ID`; the agent key from `AILK_AGENT_KEY` and nowhere else.
|
|
22
|
+
* A missing or malformed credential is refused before the adapter is loaded;
|
|
23
|
+
* every refusal, the importer's included, is thrown before any request.
|
|
24
|
+
*
|
|
25
|
+
* Pure module: no `import.meta.url`, so jest can import it directly. Spec:
|
|
26
|
+
* docs/specs/10207-create-ailk-migrate-content-import-spec.md.
|
|
27
|
+
*/
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
|
+
import { parseArgs } from "node:util";
|
|
30
|
+
import { formatImportMdxReport, importMdx, loadSiteMdxAdapter, } from "./lib/import-mdx.js";
|
|
31
|
+
import { ParseError } from "./parse-args.js";
|
|
32
|
+
import { AGENT_KEY_ENV_VAR, WORKSPACE_ID_ENV_VAR } from "./seed-workspace.js";
|
|
33
|
+
import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, } from "./topology.js";
|
|
34
|
+
/** The locale imported: the importer's default. */
|
|
35
|
+
const LOCALE = "en";
|
|
36
|
+
/** A refusal the CLI exits 2 on, thrown before any request is sent. */
|
|
37
|
+
export class MigrateContentRefusedError extends Error {
|
|
38
|
+
exitCode = 2;
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "MigrateContentRefusedError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function refuse(message) {
|
|
45
|
+
throw new MigrateContentRefusedError(`migrate-content: ${message} Nothing was sent.`);
|
|
46
|
+
}
|
|
47
|
+
/** A value given, or `undefined` for an absent or empty one. */
|
|
48
|
+
function given(value) {
|
|
49
|
+
const trimmed = value?.trim();
|
|
50
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
51
|
+
}
|
|
52
|
+
// ── The dry run's transport (D3) ────────────────────────────────────────────
|
|
53
|
+
/**
|
|
54
|
+
* A fetch that sends `GET`s through `inner` and never sends a write: a `POST`
|
|
55
|
+
* is answered `201` and a `PATCH` `200` locally, with the one member of a
|
|
56
|
+
* write's answer `importMdx` reads (`data.locale`). Any other method throws.
|
|
57
|
+
*/
|
|
58
|
+
export function readOnlyFetch(inner, locale) {
|
|
59
|
+
return (async (input, init) => {
|
|
60
|
+
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
61
|
+
if (method === "GET")
|
|
62
|
+
return inner(input, init);
|
|
63
|
+
if (method !== "POST" && method !== "PATCH") {
|
|
64
|
+
throw new Error(`a dry run sends no ${method} request`);
|
|
65
|
+
}
|
|
66
|
+
return new Response(JSON.stringify({ ok: true, data: { locale } }), {
|
|
67
|
+
status: method === "POST" ? 201 : 200,
|
|
68
|
+
headers: { "content-type": "application/json" },
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// ── The plan (D4) ───────────────────────────────────────────────────────────
|
|
73
|
+
/** A dry run's verb for what `--apply` would do. */
|
|
74
|
+
const PLAN_VERB = {
|
|
75
|
+
created: "create",
|
|
76
|
+
updated: "update",
|
|
77
|
+
unchanged: "unchanged",
|
|
78
|
+
skipped: "skipped",
|
|
79
|
+
failed: "failed",
|
|
80
|
+
};
|
|
81
|
+
function planLine(outcome) {
|
|
82
|
+
const verb = PLAN_VERB[outcome.result];
|
|
83
|
+
return "reason" in outcome
|
|
84
|
+
? `${outcome.path}: ${verb} — ${outcome.reason}`
|
|
85
|
+
: `${outcome.path}: ${verb}`;
|
|
86
|
+
}
|
|
87
|
+
// ── The command (D1–D4) ─────────────────────────────────────────────────────
|
|
88
|
+
export async function migrateContent(opts) {
|
|
89
|
+
const targetDir = resolve(opts.targetDir);
|
|
90
|
+
const env = opts.env ?? process.env;
|
|
91
|
+
const onLine = opts.onLine ?? (() => undefined);
|
|
92
|
+
const apply = opts.apply === true;
|
|
93
|
+
const apiKey = given(env[AGENT_KEY_ENV_VAR]);
|
|
94
|
+
if (apiKey === undefined) {
|
|
95
|
+
refuse(`set ${AGENT_KEY_ENV_VAR} to an agent key with the content-edit grant. It is read from the environment only — never a flag or a file.`);
|
|
96
|
+
}
|
|
97
|
+
const workspaceId = given(opts.workspaceId) ?? given(env[WORKSPACE_ID_ENV_VAR]);
|
|
98
|
+
if (workspaceId === undefined) {
|
|
99
|
+
refuse(`no workspace id was given. Pass --workspace <wksp_id>, or set ${WORKSPACE_ID_ENV_VAR}.`);
|
|
100
|
+
}
|
|
101
|
+
const flagOrigin = given(opts.apiUrl);
|
|
102
|
+
const source = flagOrigin !== undefined ? "--api-url" : HUB_ORIGIN_ENV_VAR;
|
|
103
|
+
const rawOrigin = flagOrigin ?? given(env[HUB_ORIGIN_ENV_VAR]);
|
|
104
|
+
if (rawOrigin === undefined) {
|
|
105
|
+
refuse(`no API origin was given. Pass --api-url <origin>, or set ${HUB_ORIGIN_ENV_VAR}.`);
|
|
106
|
+
}
|
|
107
|
+
let origin;
|
|
108
|
+
try {
|
|
109
|
+
origin = normalizeHubUrl(rawOrigin);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (!(err instanceof TopologyError))
|
|
113
|
+
throw err;
|
|
114
|
+
refuse(`the API origin must be an https: origin (http: only on a loopback host) with no path, query, fragment or credentials (from ${source}).`);
|
|
115
|
+
}
|
|
116
|
+
const at = `workspace ${workspaceId} at ${origin} (from ${source}).`;
|
|
117
|
+
onLine(apply
|
|
118
|
+
? `Migrating ${opts.targetDir} into ${at}`
|
|
119
|
+
: `Planning ${opts.targetDir} against ${at} Nothing is written without --apply.`);
|
|
120
|
+
const adapters = opts.adapters ?? (await loadSiteMdxAdapter(targetDir));
|
|
121
|
+
const fetch = opts.fetch ?? globalThis.fetch;
|
|
122
|
+
const result = await importMdx({
|
|
123
|
+
contentRoot: join(targetDir, "content"),
|
|
124
|
+
adapters,
|
|
125
|
+
apiOrigin: origin,
|
|
126
|
+
workspaceId,
|
|
127
|
+
apiKey,
|
|
128
|
+
defaultLocale: LOCALE,
|
|
129
|
+
fetch: apply ? fetch : readOnlyFetch(fetch, LOCALE),
|
|
130
|
+
...(opts.sleep !== undefined && { sleep: opts.sleep }),
|
|
131
|
+
// A dry run's importer lines say `created`/`updated`, which nothing was.
|
|
132
|
+
...(apply && { onLine }),
|
|
133
|
+
});
|
|
134
|
+
const { failed } = result.counts;
|
|
135
|
+
if (apply) {
|
|
136
|
+
onLine(formatImportMdxReport({ pages: [], counts: result.counts }).trimEnd());
|
|
137
|
+
if (failed > 0) {
|
|
138
|
+
onLine(`${failed} page(s) were not migrated. Fix them and re-run: create-ailk migrate-content ${opts.targetDir} --apply`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const { created, updated, unchanged, skipped } = result.counts;
|
|
143
|
+
for (const outcome of result.pages)
|
|
144
|
+
onLine(planLine(outcome));
|
|
145
|
+
onLine(`create ${created} · update ${updated} · unchanged ${unchanged} · skipped ${skipped} · failed ${failed}`);
|
|
146
|
+
onLine("Dry run: nothing was written. Re-run with --apply to write these pages.");
|
|
147
|
+
if (failed > 0) {
|
|
148
|
+
onLine(`${failed} page(s) would fail. Fix them before --apply.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { result, exitCode: failed > 0 ? 1 : 0 };
|
|
152
|
+
}
|
|
153
|
+
export const MIGRATE_CONTENT_HELP = `create-ailk migrate-content — import an existing site's pages into its workspace.
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
AILK_AGENT_KEY=<key> create-ailk migrate-content [<dir>] [--apply] [--api-url <origin>] [--workspace <wksp_id>]
|
|
157
|
+
|
|
158
|
+
Arguments:
|
|
159
|
+
<dir> The site. Defaults to the current dir.
|
|
160
|
+
|
|
161
|
+
Options:
|
|
162
|
+
--apply Write the pages. Without it the command reads the
|
|
163
|
+
workspace, prints what it would do, and writes
|
|
164
|
+
nothing.
|
|
165
|
+
--api-url <origin> The API origin that holds the workspace — on a
|
|
166
|
+
spoke, its hub's. Else AILK_API_URL. https: only
|
|
167
|
+
(http: on localhost or 127.0.0.1).
|
|
168
|
+
--workspace <wksp_id> The workspace the pages are written to. Else
|
|
169
|
+
AILK_WORKSPACE_ID.
|
|
170
|
+
-h, --help Show this help.
|
|
171
|
+
|
|
172
|
+
Each content/en/<Type>/<slug>.mdx page (and each legacy
|
|
173
|
+
content/<Type>/<slug>.mdx) is written to the workspace as a draft through
|
|
174
|
+
the API's content routes, with the agent key, which is read from
|
|
175
|
+
AILK_AGENT_KEY only and needs the content-edit grant. Nothing is published,
|
|
176
|
+
so the live site does not change. The site needs no .ailk-site-manifest.json
|
|
177
|
+
and may be a spoke; its dependencies must be installed.
|
|
178
|
+
|
|
179
|
+
A dry run prints create, update, unchanged, skipped or failed per page. It
|
|
180
|
+
cannot see a write the API would refuse at --apply (a conflict or a server
|
|
181
|
+
error), and a page whose content fails its page type is planned and saved
|
|
182
|
+
with errors the editor shows, not failed. --apply prints created, updated,
|
|
183
|
+
unchanged, skipped or failed per page, so a re-run is safe. Exit 0 when no
|
|
184
|
+
page failed, 1 when any did, 2 on a refusal (nothing is sent).
|
|
185
|
+
`;
|
|
186
|
+
export function parseMigrateContentArgs(argv) {
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = parseArgs({
|
|
190
|
+
args: argv,
|
|
191
|
+
strict: true,
|
|
192
|
+
allowPositionals: true,
|
|
193
|
+
options: {
|
|
194
|
+
apply: { type: "boolean", default: false },
|
|
195
|
+
"api-url": { type: "string" },
|
|
196
|
+
workspace: { type: "string" },
|
|
197
|
+
help: { type: "boolean", default: false, short: "h" },
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
throw new ParseError(`migrate-content: ${err.message}`);
|
|
203
|
+
}
|
|
204
|
+
const [target, ...rest] = parsed.positionals;
|
|
205
|
+
if (rest.length > 0) {
|
|
206
|
+
throw new ParseError(`migrate-content takes at most one target directory, got ${parsed.positionals.length} arguments`);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
target,
|
|
210
|
+
apply: parsed.values.apply === true,
|
|
211
|
+
apiUrl: parsed.values["api-url"],
|
|
212
|
+
workspaceId: parsed.values.workspace,
|
|
213
|
+
help: parsed.values.help === true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type ImportMdxResult, type MdxAdapterModule } from "./lib/import-mdx.js";
|
|
2
|
+
import { type Topology } from "./topology.js";
|
|
3
|
+
/** The environment variable the agent key is read from — and nowhere else. */
|
|
4
|
+
export declare const AGENT_KEY_ENV_VAR = "AILK_AGENT_KEY";
|
|
5
|
+
/** The environment variable the workspace id is read from when no flag names one. */
|
|
6
|
+
export declare const WORKSPACE_ID_ENV_VAR = "AILK_WORKSPACE_ID";
|
|
7
|
+
/** A refusal the CLI exits 2 on, thrown before any request is sent. */
|
|
8
|
+
export declare class SeedWorkspaceRefusedError extends Error {
|
|
9
|
+
readonly exitCode = 2;
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export interface SeedWorkspaceOptions {
|
|
13
|
+
/** The site. */
|
|
14
|
+
targetDir: string;
|
|
15
|
+
/** `--api-url`. */
|
|
16
|
+
apiUrl?: string | undefined;
|
|
17
|
+
/** `--workspace`. */
|
|
18
|
+
workspaceId?: string | undefined;
|
|
19
|
+
/** Injectable for tests; defaults to `process.env`. */
|
|
20
|
+
env?: NodeJS.ProcessEnv;
|
|
21
|
+
/** Injectable for tests; defaults to the site's installed adapter. */
|
|
22
|
+
adapters?: MdxAdapterModule;
|
|
23
|
+
/** Injectable for tests; defaults to `globalThis.fetch`. */
|
|
24
|
+
fetch?: typeof globalThis.fetch;
|
|
25
|
+
/** Injectable for tests; the wait before a rate-limited retry. */
|
|
26
|
+
sleep?: (ms: number) => Promise<void>;
|
|
27
|
+
/** Each report line as it happens; defaults to nothing. */
|
|
28
|
+
onLine?: (line: string) => void;
|
|
29
|
+
}
|
|
30
|
+
export declare function seedWorkspace(opts: SeedWorkspaceOptions): Promise<{
|
|
31
|
+
result: ImportMdxResult;
|
|
32
|
+
exitCode: 0 | 1;
|
|
33
|
+
}>;
|
|
34
|
+
export type ScaffoldSeedPlan = {
|
|
35
|
+
kind: "not-applicable";
|
|
36
|
+
} | {
|
|
37
|
+
kind: "skip";
|
|
38
|
+
reason: string;
|
|
39
|
+
} | {
|
|
40
|
+
kind: "seed";
|
|
41
|
+
};
|
|
42
|
+
export interface ScaffoldSeedPlanInput {
|
|
43
|
+
topology: Topology | undefined;
|
|
44
|
+
/** The scaffold's effective module set. */
|
|
45
|
+
modules: readonly string[];
|
|
46
|
+
/** Whether `--install` ran. */
|
|
47
|
+
installed: boolean;
|
|
48
|
+
env: NodeJS.ProcessEnv;
|
|
49
|
+
}
|
|
50
|
+
/** What the scaffold does about seeding, decided in D3's order. */
|
|
51
|
+
export declare function scaffoldSeedPlan(input: ScaffoldSeedPlanInput): ScaffoldSeedPlan;
|
|
52
|
+
export interface RunScaffoldSeedOptions extends ScaffoldSeedPlanInput {
|
|
53
|
+
targetDir: string;
|
|
54
|
+
onLine: (line: string) => void;
|
|
55
|
+
adapters?: MdxAdapterModule;
|
|
56
|
+
fetch?: typeof globalThis.fetch;
|
|
57
|
+
sleep?: (ms: number) => Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The whole scaffold-time step: plan, then print the skip or run the seed. A
|
|
61
|
+
* refusal is printed, never thrown, and sets no exit code (D6).
|
|
62
|
+
*/
|
|
63
|
+
export declare function runScaffoldSeed(opts: RunScaffoldSeedOptions): Promise<void>;
|
|
64
|
+
export interface SeedWorkspaceArgs {
|
|
65
|
+
target: string | undefined;
|
|
66
|
+
apiUrl: string | undefined;
|
|
67
|
+
workspaceId: string | undefined;
|
|
68
|
+
help: boolean;
|
|
69
|
+
}
|
|
70
|
+
export declare const SEED_WORKSPACE_HELP = "create-ailk seed-workspace \u2014 import a starter's pages into its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk seed-workspace [<dir>] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --api-url <origin> The site's API origin. Else AILK_API_URL. https:\n only (http: on localhost or 127.0.0.1).\n --workspace <wksp_id> The workspace the pages are written to. Else\n AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nEach content/en/<Type>/<slug>.mdx page is written to the workspace as a\ndraft through the API's content routes, with the agent key, which is read\nfrom AILK_AGENT_KEY only and needs the content-edit grant. Each page prints\ncreated, updated, unchanged, skipped or failed, so a re-run is safe. The\nsite's dependencies must be installed. Exit 0 when every page was seeded, 1\nwhen any failed, 2 on a refusal (nothing is sent).\n";
|
|
71
|
+
export declare function parseSeedWorkspaceArgs(argv: string[]): SeedWorkspaceArgs;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create-ailk seed-workspace [<dir>] [--api-url <origin>] [--workspace <id>]`
|
|
3
|
+
* — import a starter scaffold's pages into its workspace (#10204), and the
|
|
4
|
+
* scaffold-time step that runs it at the end of `pnpm create ailk`.
|
|
5
|
+
*
|
|
6
|
+
* A fresh scaffold's `content/` tree is exactly its roster (spec §1), so the
|
|
7
|
+
* seed is `importMdx` over `<dir>/content` with the site's own installed
|
|
8
|
+
* adapter (D1): one `text/page` Artifact per page, and a re-run writes
|
|
9
|
+
* nothing and reports every page `unchanged`.
|
|
10
|
+
*
|
|
11
|
+
* The API origin comes from `--api-url`, else `AILK_API_URL`; the workspace
|
|
12
|
+
* id from `--workspace`, else `AILK_WORKSPACE_ID`; the agent key from
|
|
13
|
+
* `AILK_AGENT_KEY` and nowhere else — no flag and no file decides where the
|
|
14
|
+
* key goes (D2, the `seed-flows` rule, #9969 D8). Every refusal is thrown
|
|
15
|
+
* before the adapter reads a file and before any request; the origin and its
|
|
16
|
+
* source are printed before the first one. The importer owns the transport
|
|
17
|
+
* (#10203 D5): bearer header only, no redirect, the key never printed.
|
|
18
|
+
*
|
|
19
|
+
* The scaffold-time step (D3, D6) runs only on a hub with an Admin
|
|
20
|
+
* (`workspace` or `multi-tenant`), after `--install`, with all three
|
|
21
|
+
* variables set; otherwise it prints why and the command that seeds later,
|
|
22
|
+
* or nothing on a site with no Admin. It never fails the scaffold.
|
|
23
|
+
*
|
|
24
|
+
* Pure module: no `import.meta.url`, so jest can import it directly. `fetch`,
|
|
25
|
+
* `sleep`, `env` and the adapter are injectable. Spec:
|
|
26
|
+
* docs/specs/10204-seed-the-starter-s-roster-spec.md.
|
|
27
|
+
*/
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
|
+
import { parseArgs } from "node:util";
|
|
30
|
+
import { AddModuleRefusedError } from "./add-module-core.js";
|
|
31
|
+
import { ImportMdxRefusedError, formatImportMdxReport, importMdx, loadSiteMdxAdapter, } from "./lib/import-mdx.js";
|
|
32
|
+
import { ParseError } from "./parse-args.js";
|
|
33
|
+
import { readSiteRoster } from "./site-roster.js";
|
|
34
|
+
import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, readSiteTopology, } from "./topology.js";
|
|
35
|
+
/** The environment variable the agent key is read from — and nowhere else. */
|
|
36
|
+
export const AGENT_KEY_ENV_VAR = "AILK_AGENT_KEY";
|
|
37
|
+
/** The environment variable the workspace id is read from when no flag names one. */
|
|
38
|
+
export const WORKSPACE_ID_ENV_VAR = "AILK_WORKSPACE_ID";
|
|
39
|
+
/** A refusal the CLI exits 2 on, thrown before any request is sent. */
|
|
40
|
+
export class SeedWorkspaceRefusedError extends Error {
|
|
41
|
+
exitCode = 2;
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "SeedWorkspaceRefusedError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function refuse(message) {
|
|
48
|
+
throw new SeedWorkspaceRefusedError(`seed-workspace: ${message} Nothing was sent.`);
|
|
49
|
+
}
|
|
50
|
+
/** A value given, or `undefined` for an absent or empty one (D2). */
|
|
51
|
+
function given(value) {
|
|
52
|
+
const trimmed = value?.trim();
|
|
53
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
54
|
+
}
|
|
55
|
+
// ── The command (D4) ────────────────────────────────────────────────────────
|
|
56
|
+
export async function seedWorkspace(opts) {
|
|
57
|
+
const targetDir = resolve(opts.targetDir);
|
|
58
|
+
const env = opts.env ?? process.env;
|
|
59
|
+
const onLine = opts.onLine ?? (() => undefined);
|
|
60
|
+
let roster;
|
|
61
|
+
try {
|
|
62
|
+
roster = readSiteRoster(targetDir);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (!(err instanceof AddModuleRefusedError))
|
|
66
|
+
throw err;
|
|
67
|
+
refuse(`${err.message}.`);
|
|
68
|
+
}
|
|
69
|
+
if (roster === undefined) {
|
|
70
|
+
refuse(`${opts.targetDir} has no .ailk-site-manifest.json, so it is not a starter scaffold.`);
|
|
71
|
+
}
|
|
72
|
+
let topology;
|
|
73
|
+
try {
|
|
74
|
+
topology = readSiteTopology(targetDir);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (!(err instanceof TopologyError))
|
|
78
|
+
throw err;
|
|
79
|
+
refuse(`${err.message}.`);
|
|
80
|
+
}
|
|
81
|
+
if (topology === "spoke") {
|
|
82
|
+
refuse(`${opts.targetDir} is a spoke: its pages are its own MDX files, and its hub owns the workspace.`);
|
|
83
|
+
}
|
|
84
|
+
const apiKey = given(env[AGENT_KEY_ENV_VAR]);
|
|
85
|
+
if (apiKey === undefined) {
|
|
86
|
+
refuse(`set ${AGENT_KEY_ENV_VAR} to an agent key with the content-edit grant. It is read from the environment only — never a flag or a file.`);
|
|
87
|
+
}
|
|
88
|
+
const workspaceId = given(opts.workspaceId) ?? given(env[WORKSPACE_ID_ENV_VAR]);
|
|
89
|
+
if (workspaceId === undefined) {
|
|
90
|
+
refuse(`no workspace id was given. Pass --workspace <wksp_id>, or set ${WORKSPACE_ID_ENV_VAR}.`);
|
|
91
|
+
}
|
|
92
|
+
const flagOrigin = given(opts.apiUrl);
|
|
93
|
+
const source = flagOrigin !== undefined ? "--api-url" : HUB_ORIGIN_ENV_VAR;
|
|
94
|
+
const rawOrigin = flagOrigin ?? given(env[HUB_ORIGIN_ENV_VAR]);
|
|
95
|
+
if (rawOrigin === undefined) {
|
|
96
|
+
refuse(`no API origin was given. Pass --api-url <origin>, or set ${HUB_ORIGIN_ENV_VAR}.`);
|
|
97
|
+
}
|
|
98
|
+
let origin;
|
|
99
|
+
try {
|
|
100
|
+
origin = normalizeHubUrl(rawOrigin);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (!(err instanceof TopologyError))
|
|
104
|
+
throw err;
|
|
105
|
+
refuse(`the API origin must be an https: origin (http: only on a loopback host) with no path, query, fragment or credentials (from ${source}).`);
|
|
106
|
+
}
|
|
107
|
+
onLine(`Seeding ${opts.targetDir} into workspace ${workspaceId} at ${origin} (from ${source}).`);
|
|
108
|
+
const adapters = opts.adapters ?? (await loadSiteMdxAdapter(targetDir));
|
|
109
|
+
const result = await importMdx({
|
|
110
|
+
contentRoot: join(targetDir, "content"),
|
|
111
|
+
adapters,
|
|
112
|
+
apiOrigin: origin,
|
|
113
|
+
workspaceId,
|
|
114
|
+
apiKey,
|
|
115
|
+
...(opts.fetch !== undefined && { fetch: opts.fetch }),
|
|
116
|
+
...(opts.sleep !== undefined && { sleep: opts.sleep }),
|
|
117
|
+
onLine,
|
|
118
|
+
});
|
|
119
|
+
// The report's count line alone: no page rows.
|
|
120
|
+
onLine(formatImportMdxReport({ pages: [], counts: result.counts }).trimEnd());
|
|
121
|
+
const { failed } = result.counts;
|
|
122
|
+
if (failed > 0) {
|
|
123
|
+
onLine(`${failed} page(s) were not seeded. Fix them and re-run: create-ailk seed-workspace ${opts.targetDir}`);
|
|
124
|
+
}
|
|
125
|
+
return { result, exitCode: failed > 0 ? 1 : 0 };
|
|
126
|
+
}
|
|
127
|
+
const SEED_CREDENTIALS = [
|
|
128
|
+
HUB_ORIGIN_ENV_VAR,
|
|
129
|
+
WORKSPACE_ID_ENV_VAR,
|
|
130
|
+
AGENT_KEY_ENV_VAR,
|
|
131
|
+
];
|
|
132
|
+
/** `A`, `A and B`, `A, B and C`. */
|
|
133
|
+
function joinNames(names) {
|
|
134
|
+
return names.length <= 1
|
|
135
|
+
? names.join("")
|
|
136
|
+
: `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
137
|
+
}
|
|
138
|
+
/** What the scaffold does about seeding, decided in D3's order. */
|
|
139
|
+
export function scaffoldSeedPlan(input) {
|
|
140
|
+
if (input.topology === "spoke")
|
|
141
|
+
return { kind: "not-applicable" };
|
|
142
|
+
if (!input.modules.includes("workspace") &&
|
|
143
|
+
!input.modules.includes("multi-tenant")) {
|
|
144
|
+
return { kind: "not-applicable" };
|
|
145
|
+
}
|
|
146
|
+
const unset = SEED_CREDENTIALS.filter((name) => given(input.env[name]) === undefined);
|
|
147
|
+
if (unset.length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
kind: "skip",
|
|
150
|
+
reason: `Workspace seeding skipped: ${joinNames(unset)} ${unset.length === 1 ? "is" : "are"} not set.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (!input.installed) {
|
|
154
|
+
return {
|
|
155
|
+
kind: "skip",
|
|
156
|
+
reason: "Workspace seeding skipped: the site's dependencies are not installed (pass --install, or run pnpm install first).",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { kind: "seed" };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The whole scaffold-time step: plan, then print the skip or run the seed. A
|
|
163
|
+
* refusal is printed, never thrown, and sets no exit code (D6).
|
|
164
|
+
*/
|
|
165
|
+
export async function runScaffoldSeed(opts) {
|
|
166
|
+
const plan = scaffoldSeedPlan(opts);
|
|
167
|
+
if (plan.kind === "not-applicable")
|
|
168
|
+
return;
|
|
169
|
+
if (plan.kind === "skip") {
|
|
170
|
+
opts.onLine(plan.reason);
|
|
171
|
+
opts.onLine(` To seed it later: ${AGENT_KEY_ENV_VAR}=<key> create-ailk seed-workspace ${opts.targetDir} --api-url <origin> --workspace <wksp_id>`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
opts.onLine("Seeding the workspace:");
|
|
175
|
+
try {
|
|
176
|
+
await seedWorkspace({
|
|
177
|
+
targetDir: opts.targetDir,
|
|
178
|
+
env: opts.env,
|
|
179
|
+
onLine: opts.onLine,
|
|
180
|
+
...(opts.adapters !== undefined && { adapters: opts.adapters }),
|
|
181
|
+
...(opts.fetch !== undefined && { fetch: opts.fetch }),
|
|
182
|
+
...(opts.sleep !== undefined && { sleep: opts.sleep }),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if (!(err instanceof SeedWorkspaceRefusedError) &&
|
|
187
|
+
!(err instanceof ImportMdxRefusedError)) {
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
opts.onLine(err.message);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export const SEED_WORKSPACE_HELP = `create-ailk seed-workspace — import a starter's pages into its workspace.
|
|
194
|
+
|
|
195
|
+
Usage:
|
|
196
|
+
AILK_AGENT_KEY=<key> create-ailk seed-workspace [<dir>] [--api-url <origin>] [--workspace <wksp_id>]
|
|
197
|
+
|
|
198
|
+
Arguments:
|
|
199
|
+
<dir> The site. Defaults to the current dir.
|
|
200
|
+
|
|
201
|
+
Options:
|
|
202
|
+
--api-url <origin> The site's API origin. Else AILK_API_URL. https:
|
|
203
|
+
only (http: on localhost or 127.0.0.1).
|
|
204
|
+
--workspace <wksp_id> The workspace the pages are written to. Else
|
|
205
|
+
AILK_WORKSPACE_ID.
|
|
206
|
+
-h, --help Show this help.
|
|
207
|
+
|
|
208
|
+
Each content/en/<Type>/<slug>.mdx page is written to the workspace as a
|
|
209
|
+
draft through the API's content routes, with the agent key, which is read
|
|
210
|
+
from AILK_AGENT_KEY only and needs the content-edit grant. Each page prints
|
|
211
|
+
created, updated, unchanged, skipped or failed, so a re-run is safe. The
|
|
212
|
+
site's dependencies must be installed. Exit 0 when every page was seeded, 1
|
|
213
|
+
when any failed, 2 on a refusal (nothing is sent).
|
|
214
|
+
`;
|
|
215
|
+
export function parseSeedWorkspaceArgs(argv) {
|
|
216
|
+
let parsed;
|
|
217
|
+
try {
|
|
218
|
+
parsed = parseArgs({
|
|
219
|
+
args: argv,
|
|
220
|
+
strict: true,
|
|
221
|
+
allowPositionals: true,
|
|
222
|
+
options: {
|
|
223
|
+
"api-url": { type: "string" },
|
|
224
|
+
workspace: { type: "string" },
|
|
225
|
+
help: { type: "boolean", default: false, short: "h" },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
throw new ParseError(`seed-workspace: ${err.message}`);
|
|
231
|
+
}
|
|
232
|
+
const [target, ...rest] = parsed.positionals;
|
|
233
|
+
if (rest.length > 0) {
|
|
234
|
+
throw new ParseError(`seed-workspace takes at most one target directory, got ${parsed.positionals.length} arguments`);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
target,
|
|
238
|
+
apiUrl: parsed.values["api-url"],
|
|
239
|
+
workspaceId: parsed.values.workspace,
|
|
240
|
+
help: parsed.values.help === true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -49,14 +49,23 @@ export interface TemplateRecord {
|
|
|
49
49
|
export declare class TemplateRecordError extends Error {
|
|
50
50
|
constructor(message: string);
|
|
51
51
|
}
|
|
52
|
+
export interface ParseTemplateRecordOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Called with each traversal-safe `files` key that is not a recordable path
|
|
55
|
+
* — a path a later create-ailk stopped recording, such as a generated file
|
|
56
|
+
* (#10173) — instead of refusing the record. The row is left out of the
|
|
57
|
+
* result. An unsafe key still refuses.
|
|
58
|
+
*/
|
|
59
|
+
onUnrecordable?: (key: string) => void;
|
|
60
|
+
}
|
|
52
61
|
/**
|
|
53
62
|
* Parse `.ailk-template-files.json` text, refusing — naming the field — on:
|
|
54
63
|
* JSON that does not parse; `schema !== 1`; an unknown top-level key; an
|
|
55
|
-
* empty `projectName`; a `files` key that is not a recordable path
|
|
56
|
-
* `sha256` that is not 64 lowercase hex; a
|
|
57
|
-
* string (#8522 D1).
|
|
64
|
+
* empty `projectName`; a `files` key that is not a recordable path (unless
|
|
65
|
+
* `onUnrecordable` takes it); a `sha256` that is not 64 lowercase hex; a
|
|
66
|
+
* `version` that is not a semver string (#8522 D1).
|
|
58
67
|
*/
|
|
59
|
-
export declare function parseTemplateRecord(text: string): TemplateRecord;
|
|
68
|
+
export declare function parseTemplateRecord(text: string, opts?: ParseTemplateRecordOptions): TemplateRecord;
|
|
60
69
|
/** `record` as `scaffold()` and `update` write it: sorted keys, trailing newline. */
|
|
61
70
|
export declare function serializeTemplateRecord(record: TemplateRecord): string;
|
|
62
71
|
export interface PlanTemplateRecordOptions {
|
package/dist/template-record.js
CHANGED
|
@@ -128,11 +128,11 @@ function isSafeRecordKey(key) {
|
|
|
128
128
|
/**
|
|
129
129
|
* Parse `.ailk-template-files.json` text, refusing — naming the field — on:
|
|
130
130
|
* JSON that does not parse; `schema !== 1`; an unknown top-level key; an
|
|
131
|
-
* empty `projectName`; a `files` key that is not a recordable path
|
|
132
|
-
* `sha256` that is not 64 lowercase hex; a
|
|
133
|
-
* string (#8522 D1).
|
|
131
|
+
* empty `projectName`; a `files` key that is not a recordable path (unless
|
|
132
|
+
* `onUnrecordable` takes it); a `sha256` that is not 64 lowercase hex; a
|
|
133
|
+
* `version` that is not a semver string (#8522 D1).
|
|
134
134
|
*/
|
|
135
|
-
export function parseTemplateRecord(text) {
|
|
135
|
+
export function parseTemplateRecord(text, opts = {}) {
|
|
136
136
|
let doc;
|
|
137
137
|
try {
|
|
138
138
|
doc = JSON.parse(text);
|
|
@@ -167,6 +167,10 @@ export function parseTemplateRecord(text) {
|
|
|
167
167
|
throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files" has an unsafe key "${key}"`);
|
|
168
168
|
}
|
|
169
169
|
if (!isRecordable(key)) {
|
|
170
|
+
if (opts.onUnrecordable !== undefined) {
|
|
171
|
+
opts.onUnrecordable(key);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
170
174
|
throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files" has a key "${key}" that is not a recordable path`);
|
|
171
175
|
}
|
|
172
176
|
if (typeof value !== "object" || value === null) {
|
package/dist/update.d.ts
CHANGED
|
@@ -40,11 +40,24 @@ export interface UpdateResult {
|
|
|
40
40
|
path: string;
|
|
41
41
|
reason: string;
|
|
42
42
|
}>;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Per file left under review or skipped — or a relative import the written
|
|
45
|
+
* code names that the site will not have (`missing`) — the template tests
|
|
46
|
+
* (#8704) and the other template files (`importers`, #10173) held with it
|
|
47
|
+
* — the files that import it, or, when it is a file staying at the site's
|
|
48
|
+
* version, the written files it imports — which keep the site's version so
|
|
49
|
+
* the tree never imports what it lacks.
|
|
50
|
+
*/
|
|
44
51
|
heldBack: Array<{
|
|
45
52
|
file: string;
|
|
46
53
|
tests: string[];
|
|
54
|
+
importers?: string[];
|
|
55
|
+
missing?: true;
|
|
47
56
|
}>;
|
|
57
|
+
/** The `project.yaml` `env_contract` rows added for env vars the written files read (#10172). */
|
|
58
|
+
envRowsAdded?: string[];
|
|
59
|
+
/** Record rows dropped because the path is no longer recordable (#10173). */
|
|
60
|
+
droppedRows?: string[];
|
|
48
61
|
/** The mdx-body pages under `content/` given a `# ` heading this run (#7495). */
|
|
49
62
|
headingsAdded: string[];
|
|
50
63
|
backfilled?: {
|
|
@@ -52,6 +65,23 @@ export interface UpdateResult {
|
|
|
52
65
|
files: number;
|
|
53
66
|
};
|
|
54
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The site's messages catalog with every key the template's copy has and it
|
|
70
|
+
* lacks (#10173): the site's values always win, nothing is removed, and the
|
|
71
|
+
* keys it gains follow its own. A key that is an object on one side and a
|
|
72
|
+
* value on the other is a conflict, and nothing is merged.
|
|
73
|
+
*/
|
|
74
|
+
export declare function mergeMessagesAdditively(ours: string, theirs: string): {
|
|
75
|
+
status: "merged";
|
|
76
|
+
text: string;
|
|
77
|
+
added: number;
|
|
78
|
+
} | {
|
|
79
|
+
status: "conflict";
|
|
80
|
+
keys: string[];
|
|
81
|
+
} | {
|
|
82
|
+
status: "invalid";
|
|
83
|
+
reason: string;
|
|
84
|
+
};
|
|
55
85
|
export declare function updateSite(opts: UpdateSiteOptions): Promise<UpdateResult>;
|
|
56
86
|
/** The printed report (D9): one section per non-empty list, in order. */
|
|
57
87
|
export declare function formatUpdateReport(result: UpdateResult): string;
|
|
@@ -59,6 +89,6 @@ export interface UpdateArgs {
|
|
|
59
89
|
target: string | undefined;
|
|
60
90
|
help: boolean;
|
|
61
91
|
}
|
|
62
|
-
export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file, or a root tools/jest file the\napp jest configs require, whose template copy changed is overwritten. An\nedited file whose template copy also changed is three-way merged with\n`git` (git must be on PATH); a conflict leaves the file alone and is\nlisted under \"Needs review\". Every template
|
|
92
|
+
export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file, or a root tools/jest file the\napp jest configs require, whose template copy changed is overwritten. An\nedited file whose template copy also changed is three-way merged with\n`git` (git must be on PATH); a conflict leaves the file alone and is\nlisted under \"Needs review\"; an apps/web/messages catalog instead gains the\ntemplate's new keys and keeps every value it has, and is reviewed only when a\nkey is an object on one side and a value on the other. Every template file,\ntest or not, that imports a file left under \"Needs review\" or \"Skipped\" (by a\nrelative or @/ import, transitively) is held at the site's version with it,\nits record entry is not advanced, and it is listed under \"Held back with\n<file>\" \u2014 reconcile the file, then run update again to bring it forward. A\nwritten file whose import names a file the site will not have is held the\nsame way, under \"Held back with <file> (not in this site)\". Every other file\nin the run still updates. When project.yaml has an env_contract block, each\nenv var the written files read that has no row gains one; no row is edited.\nA record row at a path create-ailk no longer records is dropped and listed.\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. A site scaffolded before\n@working-theory/mcp-server has its copied apps/mcp moved to the boot set:\nthe server and each module's paid MCP pack join apps/mcp/package.json, the\ncopied server files go, and apps/mcp/src/lib/paid-tools.generated.ts is\nrewritten on every run. A site scaffolded before @working-theory/api has its\ncopied apps/api moved to the boot file: the app becomes @working-theory/app-api,\nthe package and the six @working-theory/api-<module> packages join\napps/api/package.json, its bins and build:vercel delegate to the package, and\nthe copied server files go. Run `pnpm install` after either move, since the\napp's package.json gained dependencies. Each mdx-body page in content/ with no\n'# ' heading gets its title and description written into its body as its\nheading and intro, listed under \"Page heading added\"; a page with one heading\nis left as it is, and one with several, or one whose frontmatter it cannot\nread, is listed under \"Needs review\". Commit before running, and review the\ndiff after.\n";
|
|
63
93
|
/** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
|
|
64
94
|
export declare function parseUpdateArgs(argv: string[]): UpdateArgs;
|