@capacms/sdk 1.0.0-next.0 → 1.0.0-next.10

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +1754 -156
  3. package/bin/capa-codegen.js +192 -5
  4. package/bin/capa.js +235 -0
  5. package/bin/graphql-project.js +142 -0
  6. package/bin/project-env.js +58 -0
  7. package/dist/client.d.ts +5 -0
  8. package/dist/client.js +17 -0
  9. package/dist/codegen.d.ts +55 -0
  10. package/dist/codegen.js +320 -39
  11. package/dist/config.d.ts +5 -36
  12. package/dist/config.js +47 -1
  13. package/dist/esm/image/index.d.ts +120 -0
  14. package/dist/esm/image/index.js +250 -0
  15. package/dist/esm/image/shared-params.generated.d.ts +190 -0
  16. package/dist/esm/image/shared-params.generated.js +461 -0
  17. package/dist/esm/nextjs/image-loader.d.ts +60 -0
  18. package/dist/esm/nextjs/image-loader.js +67 -0
  19. package/dist/esm/nextjs/overlay.d.ts +30 -0
  20. package/dist/esm/nextjs/overlay.js +75 -0
  21. package/dist/esm/overlay/index.d.ts +32 -0
  22. package/dist/esm/overlay/index.js +576 -0
  23. package/dist/esm/overlay/protocol.d.ts +187 -0
  24. package/dist/esm/overlay/protocol.js +240 -0
  25. package/dist/esm/package.json +4 -0
  26. package/dist/graphql-codegen.d.ts +117 -0
  27. package/dist/graphql-codegen.js +705 -0
  28. package/dist/http.js +1 -1
  29. package/dist/image/index.d.ts +120 -0
  30. package/dist/image/index.js +257 -0
  31. package/dist/image/shared-params.generated.d.ts +190 -0
  32. package/dist/image/shared-params.generated.js +471 -0
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -1
  35. package/dist/next/attrs.d.ts +98 -0
  36. package/dist/next/attrs.js +125 -0
  37. package/dist/next/client.d.ts +176 -32
  38. package/dist/next/client.js +212 -90
  39. package/dist/next/entry-fields.d.ts +162 -0
  40. package/dist/next/entry-fields.js +2 -0
  41. package/dist/next/errors.d.ts +136 -0
  42. package/dist/next/errors.js +214 -0
  43. package/dist/next/field-names.d.ts +37 -0
  44. package/dist/next/field-names.js +145 -0
  45. package/dist/next/graphql/build.d.ts +27 -0
  46. package/dist/next/graphql/build.js +98 -0
  47. package/dist/next/graphql/documents.d.ts +67 -0
  48. package/dist/next/graphql/documents.js +35 -0
  49. package/dist/next/graphql/edit-mode.d.ts +16 -0
  50. package/dist/next/graphql/edit-mode.js +93 -0
  51. package/dist/next/graphql/filter-values.d.ts +34 -0
  52. package/dist/next/graphql/filter-values.js +96 -0
  53. package/dist/next/graphql/introspection.d.ts +89 -0
  54. package/dist/next/graphql/introspection.js +102 -0
  55. package/dist/next/graphql/plan.d.ts +115 -0
  56. package/dist/next/graphql/plan.js +531 -0
  57. package/dist/next/graphql/request.d.ts +228 -0
  58. package/dist/next/graphql/request.js +283 -0
  59. package/dist/next/graphql/rest.d.ts +66 -0
  60. package/dist/next/graphql/rest.js +502 -0
  61. package/dist/next/graphql/selection.d.ts +55 -0
  62. package/dist/next/graphql/selection.js +212 -0
  63. package/dist/next/graphql/sha256.d.ts +13 -0
  64. package/dist/next/graphql/sha256.js +86 -0
  65. package/dist/next/graphql/summary.d.ts +83 -0
  66. package/dist/next/graphql/summary.js +151 -0
  67. package/dist/next/graphql/tree-layout.d.ts +36 -0
  68. package/dist/next/graphql/tree-layout.js +20 -0
  69. package/dist/next/graphql/tree.d.ts +171 -0
  70. package/dist/next/graphql/tree.js +249 -0
  71. package/dist/next/graphql/typed.d.ts +261 -0
  72. package/dist/next/graphql/typed.js +146 -0
  73. package/dist/next/index.d.ts +30 -3
  74. package/dist/next/index.js +34 -1
  75. package/dist/next/inflate.d.ts +51 -0
  76. package/dist/next/inflate.js +243 -0
  77. package/dist/next/key-family.d.ts +31 -0
  78. package/dist/next/key-family.js +66 -0
  79. package/dist/next/select-types.d.ts +58 -5
  80. package/dist/next/system-keys.d.ts +27 -0
  81. package/dist/next/system-keys.js +42 -0
  82. package/dist/nextjs/image-loader.d.ts +60 -0
  83. package/dist/nextjs/image-loader.js +71 -0
  84. package/dist/nextjs/index.d.ts +484 -5
  85. package/dist/nextjs/index.js +704 -9
  86. package/dist/nextjs/overlay.d.ts +30 -0
  87. package/dist/nextjs/overlay.js +78 -0
  88. package/dist/overlay/index.d.ts +32 -0
  89. package/dist/overlay/index.js +596 -0
  90. package/dist/overlay/protocol.d.ts +187 -0
  91. package/dist/overlay/protocol.js +253 -0
  92. package/package.json +70 -15
@@ -2,9 +2,29 @@
2
2
  /**
3
3
  * capa-codegen — write a tenant's generated types to disk.
4
4
  *
5
- * CAPA_BASE_URL=... CAPA_API_KEY=... CAPA_TENANT_ID=... \
5
+ * CAPA_API_URL=... CAPA_KEY=... CAPA_TENANT_ID=... \
6
6
  * capa-codegen --out src/capa-types.ts
7
7
  *
8
+ * CAPA_API_URL and CAPA_KEY are the names every Capa tool reads;
9
+ * CAPA_BASE_URL and CAPA_API_KEY still work as aliases. A legacy key reads the
10
+ * /v2 schema with CAPA_TENANT_ID. A cap_ key, which /v2 does not take, reads
11
+ * the same interfaces from the key's GraphQL schema, with no tenant id; so
12
+ * does `--schema <file>`, a schema `--save-schema` wrote.
13
+ *
14
+ * GraphQL: the key's schema as types plus one TypedDocument per operation
15
+ * found in the project's .graphql files and gql`` templates. Needs the
16
+ * `graphql` package installed; no tenant id, since /api/ reads it from the key.
17
+ *
18
+ * CAPA_API_URL=... CAPA_KEY=... \
19
+ * capa-codegen --graphql --out src/capa-graphql.ts [--documents src] [--schema introspection.json]
20
+ *
21
+ * The variables are read from the shell first, then from the project's .env
22
+ * files as `next dev` reads them (.env.local, .env), so a Next.js site needs
23
+ * nothing more. `--watch` stays running and writes the module again whenever
24
+ * a document changes, and when the key's schema does (read again every
25
+ * minute). `--save-schema <file>` writes the schema it read, for a later
26
+ * `--schema <file>` run with no key, as in CI.
27
+ *
8
28
  * Exit codes: 0 wrote or already current, 1 failed, 2 --check found a diff.
9
29
  * `--check` is the CI mode: it fails when the committed file is out of date
10
30
  * with the tenant's schema, which is the whole point of committing it.
@@ -12,20 +32,187 @@
12
32
  const fs = require("node:fs");
13
33
  const path = require("node:path");
14
34
  const { generate } = require("../dist/codegen.js");
35
+ const { loadProjectEnv } = require("./project-env.js");
36
+
37
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
15
38
 
16
39
  function arg(name, fallback) {
17
40
  const i = process.argv.indexOf(name);
18
41
  return i === -1 ? fallback : process.argv[i + 1];
19
42
  }
20
43
 
44
+ /** How often --watch looks at the documents, and reads the key's schema again. */
45
+ const WATCH_FILES_MS = 500;
46
+ const WATCH_SCHEMA_MS = 60_000;
47
+
48
+ /** One run of --graphql over `introspection`: the module written, or why not. 0, 1 or 2 as the exit codes above. */
49
+ function writeModule(introspection, { out, roots, check }) {
50
+ const { generateGraphQLModule } = require("../dist/graphql-codegen.js");
51
+ const { findDocuments, printProblems } = require("./graphql-project.js");
52
+ const { documents, skipped } = findDocuments(roots);
53
+ const result = generateGraphQLModule(introspection, documents);
54
+ for (const s of [...skipped, ...result.skipped].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)) {
55
+ console.error(`capa-codegen: skipped ${s.file}:${s.line}: ${s.reason}.`);
56
+ }
57
+ if (result.warnings.length) {
58
+ console.error(`capa-codegen: ${plural(result.warnings.length, "use")} of a deprecated field, argument or value, which a later Capa-Version removes:`);
59
+ printProblems(result.warnings);
60
+ }
61
+ if (!result.source) {
62
+ console.error(`capa-codegen: ${result.problems.length} problem(s) in your GraphQL documents:`);
63
+ printProblems(result.problems);
64
+ return 1;
65
+ }
66
+ const target = path.resolve(process.cwd(), out);
67
+ const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
68
+ if (existing === result.source) {
69
+ console.log(`capa-codegen: ${out} is up to date (${plural(result.operations.length, "operation")}).`);
70
+ return 0;
71
+ }
72
+ if (check) {
73
+ console.error(`capa-codegen: ${out} is OUT OF DATE with the key's schema or your documents.\n Run: capa-codegen --graphql --out ${out}`);
74
+ return 2;
75
+ }
76
+ fs.writeFileSync(target, result.source, "utf8");
77
+ console.log(`capa-codegen: wrote ${out}: the schema's types and ${plural(result.operations.length, "typed document")}.`);
78
+ return 0;
79
+ }
80
+
81
+ /** What the document files under `roots` are now, but for the module codegen writes: a change to any is a new run. */
82
+ function documentsSignature(roots, out) {
83
+ const { listDocumentFiles } = require("./graphql-project.js");
84
+ const skip = path.resolve(process.cwd(), out);
85
+ return listDocumentFiles(roots)
86
+ .filter((file) => file !== skip)
87
+ .map((file) => {
88
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
89
+ return stat ? `${file}:${stat.mtimeMs}:${stat.size}` : file;
90
+ })
91
+ .join("\n");
92
+ }
93
+
94
+ /**
95
+ * --watch: the module is written again whenever a document changes, and when
96
+ * the key's schema does. A problem is printed and the last good module stays
97
+ * in place, so `next dev` keeps running on it; Ctrl-C stops the watch.
98
+ */
99
+ async function watchModule(options, readSchema) {
100
+ let introspection = await readSchema();
101
+ let signature = documentsSignature(options.roots, options.out);
102
+ writeModule(introspection, options);
103
+ console.log(`capa-codegen: watching ${options.roots} for changes to your GraphQL documents. Stop with Ctrl-C.`);
104
+ let schemaReadAt = Date.now();
105
+ let busy = false;
106
+ const tick = async () => {
107
+ if (busy) return;
108
+ busy = true;
109
+ try {
110
+ let changed = false;
111
+ const now = documentsSignature(options.roots, options.out);
112
+ if (now !== signature) {
113
+ signature = now;
114
+ changed = true;
115
+ }
116
+ if (Date.now() - schemaReadAt >= WATCH_SCHEMA_MS) {
117
+ schemaReadAt = Date.now();
118
+ const fresh = await readSchema();
119
+ if (JSON.stringify(fresh) !== JSON.stringify(introspection)) {
120
+ console.log("capa-codegen: the key's schema changed.");
121
+ introspection = fresh;
122
+ changed = true;
123
+ }
124
+ }
125
+ if (changed) writeModule(introspection, options);
126
+ } catch (error) {
127
+ console.error(`capa-codegen: ${error.message}`);
128
+ } finally {
129
+ busy = false;
130
+ }
131
+ };
132
+ const timer = setInterval(tick, WATCH_FILES_MS);
133
+ return new Promise((resolve) => {
134
+ for (const signal of ["SIGINT", "SIGTERM"]) {
135
+ process.once(signal, () => {
136
+ clearInterval(timer);
137
+ resolve(0);
138
+ });
139
+ }
140
+ });
141
+ }
142
+
143
+ async function graphqlMain(check) {
144
+ const { defaultDocuments, loadIntrospection } = require("./graphql-project.js");
145
+ const options = { out: arg("--out", "capa-graphql.ts"), roots: arg("--documents", defaultDocuments()), check };
146
+ const schemaFile = arg("--schema");
147
+ const saveTo = arg("--save-schema");
148
+ const readSchema = async () => {
149
+ const introspection = await loadIntrospection(schemaFile);
150
+ if (saveTo) {
151
+ fs.writeFileSync(path.resolve(process.cwd(), saveTo), JSON.stringify({ data: introspection }, null, 1) + "\n", "utf8");
152
+ console.log(`capa-codegen: saved the key's schema to ${saveTo}.`);
153
+ }
154
+ return introspection;
155
+ };
156
+ if (process.argv.includes("--watch")) return watchModule(options, readSchema);
157
+ return writeModule(await readSchema(), options);
158
+ }
159
+
160
+ /**
161
+ * The REST types from the key's GraphQL schema, or from `--schema <file>`:
162
+ * the interfaces the /v2 schema gives a legacy key, for a cap_ key, which /v2
163
+ * refuses. Needs GraphQL on the API, and says so when it is off.
164
+ */
165
+ async function restFromGraphQLMain(out, check) {
166
+ const { restTypesFromIntrospection } = require("../dist/codegen.js");
167
+ const { loadIntrospection } = require("./graphql-project.js");
168
+ let introspection;
169
+ try {
170
+ introspection = await loadIntrospection(arg("--schema"));
171
+ } catch (error) {
172
+ // 404 or 405: this API does not serve GraphQL (CAPA_API_GRAPHQL=off).
173
+ if (error.status !== 404 && error.status !== 405) throw error;
174
+ throw new Error(
175
+ `${error.message} capa-codegen reads a cap_ key's models from its GraphQL schema: ` +
176
+ "set CAPA_KEY to a pk_ key and CAPA_TENANT_ID to read the /v2 schema instead.",
177
+ );
178
+ }
179
+ const result = restTypesFromIntrospection(introspection);
180
+ const omitted = result.restOnly;
181
+ if (omitted.length) {
182
+ const names = omitted.length === 1 ? omitted[0] : `${omitted.slice(0, -1).join(", ")} and ${omitted[omitted.length - 1]}`;
183
+ console.error(
184
+ `capa-codegen: ${names} ${omitted.length === 1 ? "is" : "are"} not in the key's GraphQL schema, ` +
185
+ "since their type names would collide, so no type is written for them. entries.list reads them untyped.",
186
+ );
187
+ }
188
+ const target = path.resolve(process.cwd(), out);
189
+ const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
190
+ if (existing === result.source) {
191
+ console.log(`capa-codegen: ${out} is up to date with the key's GraphQL schema.`);
192
+ return 0;
193
+ }
194
+ if (check) {
195
+ console.error(`capa-codegen: ${out} is OUT OF DATE with the key's GraphQL schema.\n Run: capa-codegen --out ${out}`);
196
+ return 2;
197
+ }
198
+ fs.writeFileSync(target, result.source, "utf8");
199
+ console.log(`capa-codegen: wrote ${out}: ${result.types.length} types, from the key's GraphQL schema.`);
200
+ return 0;
201
+ }
202
+
21
203
  async function main() {
22
- const out = arg("--out", "capa-types.ts");
204
+ loadProjectEnv();
23
205
  const check = process.argv.includes("--check");
206
+ if (process.argv.includes("--graphql")) return graphqlMain(check);
207
+
208
+ const out = arg("--out", "capa-types.ts");
24
209
  const config = {
25
- baseUrl: process.env.CAPA_BASE_URL || "",
26
- apiKey: process.env.CAPA_API_KEY || "",
210
+ baseUrl: process.env.CAPA_API_URL || process.env.CAPA_BASE_URL || "",
211
+ apiKey: process.env.CAPA_KEY || process.env.CAPA_API_KEY || "",
27
212
  tenantId: process.env.CAPA_TENANT_ID || "",
28
213
  };
214
+ // /v2 reads legacy keys only: a cap_ key reads the same models over GraphQL.
215
+ if (config.apiKey.startsWith("cap_") || arg("--schema")) return restFromGraphQLMain(out, check);
29
216
  const target = path.resolve(process.cwd(), out);
30
217
  const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
31
218
 
@@ -44,7 +231,7 @@ async function main() {
44
231
  return 2;
45
232
  }
46
233
  fs.writeFileSync(target, result.source, "utf8");
47
- console.log(`capa-codegen: wrote ${out} — ${result.types.length} types (schema ${result.checksum}).`);
234
+ console.log(`capa-codegen: wrote ${out}: ${result.types.length} types (schema ${result.checksum}).`);
48
235
  return 0;
49
236
  }
50
237
 
package/bin/capa.js ADDED
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * capa — the SDK's command line.
4
+ *
5
+ * capa codegen [...] same as capa-codegen; add --graphql for GraphQL types
6
+ * capa persist [...] register the project's GraphQL documents as persisted queries
7
+ * capa <anything else> @capacms/cli's command, when the project has that package
8
+ *
9
+ * `capa persist` finds every named operation in the project's .graphql files
10
+ * and marked templates (`#graphql`, `/* capa *\/`, gql), checks it against the
11
+ * key's schema, and sends it once with its sha256 to the API, which stores it.
12
+ * After that, `client.graphql(doc, vars, { persisted: true })` sends only the
13
+ * hash. The hashes are those of the exact text `capa-codegen --graphql`
14
+ * writes, so run both from the same documents. A document written as a
15
+ * literal is sent as written, so its own text is stored too, as
16
+ * `<Name> (literal)`.
17
+ *
18
+ * CAPA_API_URL=https://api.capacms.com CAPA_DRAFT_KEY=cap_test_... capa persist [--documents src] [--manifest persisted.json]
19
+ *
20
+ * Two things must hold for a document to be stored (spec 17, amendment 28).
21
+ * The POST reaches the host that stores documents: api.capacms.com sends
22
+ * every POST there, and a self-hosted stack whose read host stores nothing
23
+ * names the other host in CAPA_ADMIN_URL, which wins over CAPA_API_URL. And
24
+ * the key is a development key, since a production key ships in site
25
+ * bundles: the key is CAPA_DRAFT_KEY (CAPA_KEY when that is unset), and a
26
+ * production key is refused before anything is sent.
27
+ *
28
+ * Every registration asks for a pin (`Capa-Persist: pin`, amendment 69). The
29
+ * store drops unpinned documents before any pinned one, so the documents a
30
+ * developer registers in the Explorer or a draft preview cannot push a live
31
+ * site's out. A document stored without its pin is exposed to exactly that,
32
+ * so it counts as a failure, not as stored.
33
+ *
34
+ * A pin names its build when the build says what it is (amendment 134):
35
+ * `Capa-Persist: pin; release=<id>; env=<env>`. The store keeps the pins of
36
+ * the latest 3 production releases before anything else, so a run of preview
37
+ * builds cannot push production's documents out. The label comes from
38
+ * --release and --env, else CAPA_RELEASE and CAPA_RELEASE_ENV, else the host
39
+ * the build runs on: Vercel (VERCEL_GIT_COMMIT_SHA, VERCEL_ENV) or Netlify
40
+ * (COMMIT_REF, CONTEXT). Both or neither, checked before anything is sent.
41
+ * Exit codes: 0 every document stored and pinned, 1 failed, 3 some were not.
42
+ */
43
+ const fs = require("node:fs");
44
+ const path = require("node:path");
45
+
46
+ /** The API's rules for a pin's labels (apps/api graphql/apq.ts, amendment 134). */
47
+ const RELEASE = /^[A-Za-z0-9._-]{1,64}$/;
48
+ const RELEASE_ENV = /^[a-z][a-z0-9-]{0,31}$/;
49
+
50
+ /** Hosts whose builds say which commit they build and where it deploys. */
51
+ const BUILD_HOSTS = [
52
+ { host: "Vercel", release: "VERCEL_GIT_COMMIT_SHA", env: "VERCEL_ENV" },
53
+ { host: "Netlify", release: "COMMIT_REF", env: "CONTEXT", when: "NETLIFY" },
54
+ ];
55
+
56
+ /**
57
+ * The build a pin belongs to, `{ release, env, from }`, or none when nothing
58
+ * names one; `{ error }` for a label the API would refuse, so nothing is sent.
59
+ * A flag wins over CAPA_RELEASE and CAPA_RELEASE_ENV, which win over the host.
60
+ */
61
+ function persistLabels(env, arg) {
62
+ const set = (value) => (typeof value === "string" && value !== "" ? value : undefined);
63
+ const host = BUILD_HOSTS.find((h) => set(env[h.env]) !== undefined && (!h.when || set(env[h.when]) !== undefined));
64
+ const pick = (flag, variable, hostVariable) => {
65
+ if (set(arg(flag)) !== undefined) return { value: arg(flag), from: flag };
66
+ if (set(env[variable]) !== undefined) return { value: env[variable], from: variable };
67
+ if (host && set(env[hostVariable]) !== undefined) return { value: env[hostVariable], from: hostVariable };
68
+ return null;
69
+ };
70
+ const release = pick("--release", "CAPA_RELEASE", host?.release);
71
+ const where = pick("--env", "CAPA_RELEASE_ENV", host?.env);
72
+ if (!release && !where) return {};
73
+ if (!release || !where) {
74
+ const [given, missing] = release ? [release, "--env (or CAPA_RELEASE_ENV)"] : [where, "--release (or CAPA_RELEASE)"];
75
+ return { error: `${given.from} names ${release ? "a release" : "an environment"} but nothing names ${release ? "where it deploys" : "the release"}: pass ${missing} too. Nothing was sent.` };
76
+ }
77
+ if (!RELEASE.test(release.value)) {
78
+ return { error: `${release.from} is ${JSON.stringify(release.value)}; a release is 1 to 64 letters, digits, dots, dashes or underscores, such as a commit or a deploy id. Nothing was sent.` };
79
+ }
80
+ if (!RELEASE_ENV.test(where.value)) {
81
+ return { error: `${where.from} is ${JSON.stringify(where.value)}; an environment is a lowercase name such as production or preview. Nothing was sent.` };
82
+ }
83
+ return { release: release.value, env: where.value, from: release.from === where.from ? release.from : `${release.from} and ${where.from}` };
84
+ }
85
+
86
+ async function persist() {
87
+ require("./project-env.js").loadProjectEnv();
88
+ const { generateGraphQLModule } = require("../dist/graphql-codegen.js");
89
+ const { apiFromEnv, arg, defaultDocuments, findDocuments, keyEnvironment, loadIntrospection, printProblems, REQUEST_TIMEOUT_MS } =
90
+ require("./graphql-project.js");
91
+ const labels = persistLabels(process.env, arg);
92
+ if (labels.error) {
93
+ console.error(`capa persist: ${labels.error}`);
94
+ return 1;
95
+ }
96
+ const api = apiFromEnv(process.env, { admin: true });
97
+ if ((await keyEnvironment(api)) === "production") {
98
+ console.error(
99
+ `capa persist: ${api.keyName} is a production key, and only a development key stores persisted documents. ` +
100
+ "Set CAPA_DRAFT_KEY to a development key (Developers > Keys in the Capa admin). Nothing was sent.",
101
+ );
102
+ return 1;
103
+ }
104
+ const introspection = await loadIntrospection(arg("--schema"), api);
105
+ const { documents } = findDocuments(arg("--documents", defaultDocuments()));
106
+ const result = generateGraphQLModule(introspection, documents);
107
+ if (result.problems.length) {
108
+ console.error(`capa persist: ${result.problems.length} problem(s) in your GraphQL documents, nothing sent:`);
109
+ printProblems(result.problems);
110
+ return 1;
111
+ }
112
+ const pin = labels.release ? `pin; release=${labels.release}; env=${labels.env}` : "pin";
113
+ console.log(
114
+ labels.release
115
+ ? `capa persist: pinning for release ${labels.release} in ${labels.env} (from ${labels.from}).`
116
+ : "capa persist: pinning with no release. On a deploy, pass --release <commit> --env production (or preview) so the API keeps production's documents first.",
117
+ );
118
+ let notStored = 0;
119
+ let unpinned = 0;
120
+ let declined = 0;
121
+ const rows = [];
122
+ const sends = result.operations.flatMap((operation) => [
123
+ operation,
124
+ ...(operation.literal && operation.literal.document !== operation.document
125
+ ? [{ name: operation.name, label: `${operation.name} (literal)`, ...operation.literal }]
126
+ : []),
127
+ ]);
128
+ for (const operation of sends) {
129
+ const response = await api.fetchImpl(`${api.baseUrl}/api/graphql`, {
130
+ method: "POST",
131
+ headers: {
132
+ Accept: "application/json",
133
+ "Content-Type": "application/json",
134
+ "x-api-key": api.apiKey,
135
+ "Capa-Version": api.version,
136
+ "Capa-Persist": pin,
137
+ },
138
+ body: JSON.stringify({
139
+ query: operation.document,
140
+ operationName: operation.name,
141
+ extensions: { persistedQuery: { version: 1, sha256Hash: operation.sha256 } },
142
+ }),
143
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
144
+ });
145
+ const body = await response.json().catch(() => ({}));
146
+ const stored = body?.extensions?.persistedQuery?.registered === true;
147
+ const pinned = stored && body.extensions.persistedQuery.pinned === true;
148
+ // A body with errors and no data is a refusal of the document, whether it
149
+ // came as a 4xx or, as GraphQL over HTTP sends it on application/json, a 200.
150
+ const refusal = body && typeof body === "object" && !("data" in body) ? body.errors?.[0] : undefined;
151
+ if (!pinned) notStored++;
152
+ if (stored && !pinned) unpinned++;
153
+ if (!stored && !refusal && response.status === 200) declined++;
154
+ const code = body?.errors?.[0]?.extensions?.code;
155
+ const why = pinned
156
+ ? "stored, pinned"
157
+ : stored
158
+ ? `stored, not pinned (the API ignored Capa-Persist: ${pin}, so other registrations can push it out)`
159
+ : refusal
160
+ ? `not stored (refused, ${refusal.extensions?.code ?? "error"}: ${refusal.message})`
161
+ : `not stored (HTTP ${response.status}${code ? `, ${code}` : ""})`;
162
+ rows.push({ name: operation.label ?? operation.name, sha256: operation.sha256, stored, pinned });
163
+ console.log(`${operation.sha256} ${operation.label ?? operation.name} ${why}`);
164
+ }
165
+ const manifest = arg("--manifest");
166
+ if (manifest) {
167
+ const build = labels.release ? { release: labels.release, env: labels.env } : {};
168
+ fs.writeFileSync(path.resolve(process.cwd(), manifest), JSON.stringify({ ...build, operations: rows }, null, 2) + "\n");
169
+ }
170
+ console.log(`capa persist: ${rows.length - notStored} of ${rows.length} documents stored, pinned.`);
171
+ if (unpinned) {
172
+ console.error(
173
+ `capa persist: ${unpinned} document${unpinned === 1 ? " was" : "s were"} stored without a pin. ` +
174
+ `Upgrade the API at ${api.baseUrl} to one that pins build documents (Capa-Version ${api.version}).`,
175
+ );
176
+ }
177
+ if (declined === rows.length && rows.length) {
178
+ console.error(
179
+ `capa persist: ${api.baseUrl} stored nothing. A host that serves reads only runs documents but never stores one: ` +
180
+ "set CAPA_ADMIN_URL to the API host your Capa admin uses (https://api.capacms.com sends every POST there), " +
181
+ `and make ${api.keyName} a development key.`,
182
+ );
183
+ }
184
+ return notStored ? 3 : 0;
185
+ }
186
+
187
+ /**
188
+ * The `capa` bin of @capacms/cli, when the project has it, or null.
189
+ *
190
+ * Both packages ship a `capa` bin, and a package manager links one of them.
191
+ * So each hands over the other's commands: @capacms/cli runs this file for
192
+ * codegen and persist, and this file runs @capacms/cli for everything else
193
+ * (login, entries, init, mcp and the rest). Whichever `capa` is linked, every
194
+ * command does the same thing. Without @capacms/cli nothing changes.
195
+ */
196
+ function cliBin() {
197
+ const { createRequire } = require("node:module");
198
+ try {
199
+ const manifest = createRequire(path.join(process.cwd(), "package.json")).resolve("@capacms/cli/package.json");
200
+ const bin = JSON.parse(fs.readFileSync(manifest, "utf8")).bin;
201
+ const file = typeof bin === "string" ? bin : bin && bin.capa;
202
+ return file ? path.join(path.dirname(manifest), file) : null;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ async function main() {
209
+ const [command] = process.argv.slice(2);
210
+ if (command === "persist") return persist();
211
+ if (command === "codegen") {
212
+ process.argv.splice(2, 1);
213
+ require("./capa-codegen.js");
214
+ return null;
215
+ }
216
+ const cli = cliBin();
217
+ if (cli) {
218
+ const result = require("node:child_process").spawnSync(process.execPath, [cli, ...process.argv.slice(2)], { stdio: "inherit" });
219
+ return result.status === null ? 1 : result.status;
220
+ }
221
+ console.error(
222
+ "usage: capa codegen [--graphql] [--out file] | CAPA_API_URL=... CAPA_DRAFT_KEY=... capa persist [--documents src] [--manifest file] [--release id --env production]",
223
+ );
224
+ return 1;
225
+ }
226
+
227
+ main().then(
228
+ (code) => {
229
+ if (code !== null) process.exit(code);
230
+ },
231
+ (err) => {
232
+ console.error(`capa: ${err.message}`);
233
+ process.exit(1);
234
+ },
235
+ );
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The I/O half of `capa-codegen --graphql` and `capa persist`: find a
3
+ * project's GraphQL documents on disk, and read the key's schema from Capa (or
4
+ * from a saved introspection file, for CI without a key).
5
+ */
6
+ const fs = require("node:fs");
7
+ const path = require("node:path");
8
+ const { extractDocuments } = require("../dist/graphql-codegen.js");
9
+ const { INTROSPECTION_QUERY } = require("../dist/next/graphql/introspection.js");
10
+ const { runGraphQL } = require("../dist/next/graphql/request.js");
11
+
12
+ const SKIP = new Set(["node_modules", ".git", ".next", "dist", "build", "out", "coverage", ".turbo"]);
13
+ const EXTENSIONS = /\.(graphql|gql|[cm]?[jt]sx?)$/i;
14
+
15
+ function walk(dir, files) {
16
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
17
+ if (SKIP.has(entry.name)) continue;
18
+ const full = path.join(dir, entry.name);
19
+ if (entry.isDirectory()) walk(full, files);
20
+ else if (EXTENSIONS.test(entry.name)) files.push(full);
21
+ }
22
+ return files;
23
+ }
24
+
25
+ /** Every file under `roots` (a comma-separated list of files or folders) that may hold a document, as absolute paths. */
26
+ function listDocumentFiles(roots, cwd = process.cwd()) {
27
+ const files = [];
28
+ for (const root of roots.split(",").map((r) => r.trim()).filter(Boolean)) {
29
+ const absolute = path.resolve(cwd, root);
30
+ if (!fs.existsSync(absolute)) throw new Error(`${root} does not exist.`);
31
+ files.push(...(fs.statSync(absolute).isDirectory() ? walk(absolute, []).sort() : [absolute]));
32
+ }
33
+ return files;
34
+ }
35
+
36
+ /** Every document under `roots` (a comma-separated list of files or folders). */
37
+ function findDocuments(roots, cwd = process.cwd()) {
38
+ const documents = [];
39
+ const skipped = [];
40
+ for (const file of listDocumentFiles(roots, cwd)) {
41
+ const found = extractDocuments(path.relative(cwd, file), fs.readFileSync(file, "utf8"));
42
+ documents.push(...found.documents);
43
+ skipped.push(...found.skipped);
44
+ }
45
+ return { documents, skipped };
46
+ }
47
+
48
+ /** The first of `names` set in `env`, as `[name, value]`, or null. */
49
+ function firstSet(env, names) {
50
+ const name = names.find((n) => env[n]);
51
+ return name ? [name, env[name]] : null;
52
+ }
53
+
54
+ /**
55
+ * The API settings from env: `CAPA_API_URL` and `CAPA_KEY`, the names every
56
+ * Capa tool reads, with `CAPA_BASE_URL` and `CAPA_API_KEY` as aliases.
57
+ *
58
+ * With `admin` (for `capa persist`): the host is `CAPA_ADMIN_URL` when it is
59
+ * set, else `CAPA_API_URL`. `https://api.capacms.com` sends every POST to the
60
+ * host that stores persisted documents, so a site's own URL registers them; a
61
+ * self-hosted stack whose read host stores nothing names its admin host in
62
+ * `CAPA_ADMIN_URL`. The key is the development key, `CAPA_DRAFT_KEY` as
63
+ * `/nextjs` names it, since only a development key registers a document (spec
64
+ * 17, amendment 28). `CAPA_KEY` is read when `CAPA_DRAFT_KEY` is unset, and
65
+ * `keyName` says which one was, so a refusal can name the variable to change.
66
+ */
67
+ function apiFromEnv(env = process.env, { admin = false } = {}) {
68
+ const rawUrl = (admin && env.CAPA_ADMIN_URL) || env.CAPA_API_URL || env.CAPA_BASE_URL;
69
+ const baseUrl = (rawUrl || "").replace(/\/+$/, "");
70
+ const keyNames = admin ? ["CAPA_DRAFT_KEY", "CAPA_KEY", "CAPA_API_KEY"] : ["CAPA_KEY", "CAPA_API_KEY"];
71
+ const [keyName, apiKey] = firstSet(env, keyNames) ?? [keyNames[0], ""];
72
+ const version = env.CAPA_API_VERSION || "2026-10-01";
73
+ const missing = [!baseUrl && "CAPA_API_URL", !apiKey && keyNames[0]].filter(Boolean);
74
+ if (missing.length) {
75
+ const command = admin ? "capa persist" : "capa-codegen";
76
+ throw new Error(
77
+ `missing ${missing.join(" and ")}. Set ${missing.length > 1 ? "them" : "it"} in your shell or in .env.local, which ${command} reads as next dev does.`,
78
+ );
79
+ }
80
+ return { baseUrl, apiKey, keyName, version, fetchImpl: globalThis.fetch };
81
+ }
82
+
83
+ /** How long one request of these commands may take. */
84
+ const REQUEST_TIMEOUT_MS = 30_000;
85
+
86
+ /**
87
+ * The key's environment (`production` or `development`) from `GET /api/me`,
88
+ * or null when the API does not say (an older API, a proxy, no route).
89
+ */
90
+ async function keyEnvironment(api) {
91
+ try {
92
+ const response = await api.fetchImpl(`${api.baseUrl}/api/me`, {
93
+ headers: { Accept: "application/json", "x-api-key": api.apiKey, "Capa-Version": api.version },
94
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
95
+ });
96
+ if (!response.ok) return null;
97
+ const body = await response.json();
98
+ return typeof body?.data?.environment === "string" ? body.data.environment : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /** The key's schema: from `--schema <file>` when given, else one introspection request. */
105
+ async function loadIntrospection(schemaFile, api) {
106
+ if (schemaFile) {
107
+ const parsed = JSON.parse(fs.readFileSync(schemaFile, "utf8"));
108
+ return parsed.data ?? parsed;
109
+ }
110
+ // By POST: `capa persist` may read it from an admin host, which serves GraphQL by POST only.
111
+ const result = await runGraphQL(api ?? apiFromEnv(), INTROSPECTION_QUERY, undefined, { method: "POST" });
112
+ if (!result.data || result.errors.length) {
113
+ const first = result.errors[0];
114
+ throw new Error(`introspection failed: ${first ? `${first.code}: ${first.message}` : "no data"}`);
115
+ }
116
+ return result.data;
117
+ }
118
+
119
+ function arg(name, fallback) {
120
+ const i = process.argv.indexOf(name);
121
+ return i === -1 ? fallback : process.argv[i + 1];
122
+ }
123
+
124
+ function defaultDocuments(cwd = process.cwd()) {
125
+ return fs.existsSync(path.join(cwd, "src")) ? "src" : ".";
126
+ }
127
+
128
+ function printProblems(problems, log = console.error) {
129
+ for (const p of problems) log(` ${p.file}:${p.line}:${p.column} ${p.message}`);
130
+ }
131
+
132
+ module.exports = {
133
+ apiFromEnv,
134
+ arg,
135
+ defaultDocuments,
136
+ findDocuments,
137
+ keyEnvironment,
138
+ listDocumentFiles,
139
+ loadIntrospection,
140
+ printProblems,
141
+ REQUEST_TIMEOUT_MS,
142
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A project's .env files, read the way `next dev` reads them, so the
3
+ * commands find CAPA_API_URL and the keys where a Next.js site keeps them.
4
+ *
5
+ * Next's own loader (`@next/env`) is used when the project has Next
6
+ * installed, so `$VAR` expansion and every other rule match what the site
7
+ * sees. Without it, a small parser reads the same files in the same order:
8
+ * `.env.<mode>.local`, `.env.local`, `.env.<mode>`, `.env`, the first file
9
+ * that sets a name winning, where the mode is `production` when NODE_ENV says
10
+ * so and `development` otherwise. A variable already in the environment is
11
+ * never replaced, so the shell and CI always win.
12
+ */
13
+ const fs = require("node:fs");
14
+ const path = require("node:path");
15
+ const { createRequire } = require("node:module");
16
+
17
+ /** `@next/env`'s `loadEnvConfig`, resolved through the project's own `next`, or null. */
18
+ function nextEnvLoader(cwd) {
19
+ try {
20
+ const fromProject = createRequire(path.join(cwd, "package.json"));
21
+ const nextDir = path.dirname(fromProject.resolve("next/package.json"));
22
+ return createRequire(path.join(nextDir, "package.json"))("@next/env").loadEnvConfig;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /** `KEY=value` lines: `export ` allowed, quotes removed, `#` comments and blank lines skipped. */
29
+ function parseEnvFile(text) {
30
+ const values = {};
31
+ for (const line of text.split(/\r?\n/)) {
32
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
33
+ if (!match) continue;
34
+ const [, name, raw] = match;
35
+ const quoted = /^(["'])([\s\S]*)\1$/.exec(raw);
36
+ values[name] = quoted ? (quoted[1] === '"' ? quoted[2].replace(/\\n/g, "\n") : quoted[2]) : raw.replace(/\s+#.*$/, "");
37
+ }
38
+ return values;
39
+ }
40
+
41
+ /** Fill `env` from the project's .env files without replacing a name it already has. */
42
+ function loadProjectEnv(cwd = process.cwd(), env = process.env) {
43
+ const mode = env.NODE_ENV === "production" ? "production" : "development";
44
+ const loadEnvConfig = env === process.env ? nextEnvLoader(cwd) : null;
45
+ if (loadEnvConfig) {
46
+ loadEnvConfig(cwd, mode === "development", { info() {}, error: (message) => console.error(message) });
47
+ return;
48
+ }
49
+ for (const name of [`.env.${mode}.local`, ".env.local", `.env.${mode}`, ".env"]) {
50
+ const file = path.join(cwd, name);
51
+ if (!fs.existsSync(file)) continue;
52
+ for (const [key, value] of Object.entries(parseEnvFile(fs.readFileSync(file, "utf8")))) {
53
+ if (!(key in env)) env[key] = value;
54
+ }
55
+ }
56
+ }
57
+
58
+ module.exports = { loadProjectEnv, parseEnvFile };
package/dist/client.d.ts CHANGED
@@ -410,4 +410,9 @@ export interface SearchHit {
410
410
  * shadowing model. `getContentById` cannot be reached for such a row.
411
411
  */
412
412
  export declare function instanceIdOf(row: unknown): string | null;
413
+ /**
414
+ * The legacy `/v2/api` client, for a legacy key (`pk_`, `sk_` or unprefixed)
415
+ * and its tenant's id. For `/api/`, GraphQL and `cap_` keys, import
416
+ * `createClient` from `@capacms/sdk/next`.
417
+ */
413
418
  export declare function createClient(config: CapaConfig): CapaClient;