@myna-sh/cli 0.11.0 → 0.12.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 CHANGED
@@ -64,6 +64,30 @@ myna changes diff chs_...
64
64
 
65
65
  Run `myna --help` or `myna <command> --help` for the complete command reference. Global flags include `--json`, `--organization`, `--project`, `--token`, and `--api-url`.
66
66
 
67
+ ## Pin content to a release
68
+
69
+ `myna.lock` records the release a repository builds against, so the same commit
70
+ builds the same site. `myna pull` moves it.
71
+
72
+ ```sh
73
+ myna pull # pin to the newest release
74
+ myna pull --release 42 # pin to a specific one
75
+ myna pull --check # non-zero exit when the pin is behind (for CI)
76
+ myna pull --open-pr # bump on a branch and open a pull request
77
+ ```
78
+
79
+ `--open-pr` writes a pull request whose body is the field-level diff of every
80
+ release being taken on, so CI runs the real build against the new content before
81
+ anyone merges it. It needs `git` and the GitHub CLI. Read the pin with
82
+ `readLock()` from `@myna-sh/sdk/lock` and pass it to `createMyna({ release })`.
83
+
84
+ Add `--run` when the repository generates something from content, and the
85
+ regenerated artifact is committed with the pin that produced it:
86
+
87
+ ```sh
88
+ myna pull --open-pr --run 'pnpm build:content'
89
+ ```
90
+
67
91
  ## Environment
68
92
 
69
93
  ```text
package/dist/main.js CHANGED
@@ -1533,7 +1533,7 @@ function registerWorkspace(program) {
1533
1533
 
1534
1534
  // src/commands/schema.ts
1535
1535
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1536
- import { join as join4 } from "path";
1536
+ import { join as join5 } from "path";
1537
1537
 
1538
1538
  // src/schema-loader.ts
1539
1539
  import { existsSync as existsSync3, readdirSync, statSync } from "fs";
@@ -1769,6 +1769,8 @@ function varName(name) {
1769
1769
  // src/git.ts
1770
1770
  import { execFileSync as execFileSync2 } from "child_process";
1771
1771
  import { createHash as createHash2 } from "crypto";
1772
+ import { rmSync as rmSync2 } from "fs";
1773
+ import { join as join4 } from "path";
1772
1774
  function run(command, args, cwd) {
1773
1775
  try {
1774
1776
  return execFileSync2(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -1799,6 +1801,10 @@ function branchForContent(contents) {
1799
1801
  const digest = createHash2("sha256").update(contents.join("\0")).digest("hex").slice(0, 8);
1800
1802
  return `myna/schema-${digest}`;
1801
1803
  }
1804
+ function branchForRelease(project, release) {
1805
+ const slug = project.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project";
1806
+ return `myna/content-${slug}-r${release}`;
1807
+ }
1802
1808
  function openPullRequest(input) {
1803
1809
  if (!available("git")) throw new CliError("git is required to open a pull request.");
1804
1810
  if (!available("gh")) {
@@ -1848,6 +1854,22 @@ function dirtyFiles(cwd, paths) {
1848
1854
  if (paths.length === 0) return [];
1849
1855
  return run("git", ["status", "--porcelain", "--", ...paths], cwd).split("\n").filter(Boolean).map((line) => line.slice(3).trim());
1850
1856
  }
1857
+ function repositoryRoot(cwd) {
1858
+ return git(["rev-parse", "--show-toplevel"], cwd);
1859
+ }
1860
+ function worktreeChanges(cwd) {
1861
+ const root = repositoryRoot(cwd);
1862
+ return run("git", ["status", "--porcelain"], cwd).split("\n").filter(Boolean).map((line) => ({
1863
+ file: join4(root, line.slice(3).trim()),
1864
+ untracked: line.startsWith("??")
1865
+ }));
1866
+ }
1867
+ function discardChanges(cwd, changes) {
1868
+ for (const change of changes) {
1869
+ if (change.untracked) rmSync2(change.file, { force: true, recursive: true });
1870
+ else gitQuiet(["checkout", "--", change.file], cwd);
1871
+ }
1872
+ }
1851
1873
 
1852
1874
  // src/commands/schema.ts
1853
1875
  async function deployedSchemas(ctx, project) {
@@ -1899,7 +1921,7 @@ function registerSchema(program) {
1899
1921
  const project = ctx.requireProject();
1900
1922
  const dir = schemaDirFor(ctx.linkedRoot, opts.schemaDir);
1901
1923
  const schemas = await deployedSchemas(ctx, project);
1902
- const rendered = schemas.map((s) => ({ file: join4(dir, `${s.name}.ts`), code: schemaToDsl(s) }));
1924
+ const rendered = schemas.map((s) => ({ file: join5(dir, `${s.name}.ts`), code: schemaToDsl(s) }));
1903
1925
  const changed = rendered.filter(
1904
1926
  (r) => !existsSync4(r.file) || readFileSync3(r.file, "utf8") !== r.code
1905
1927
  );
@@ -2108,7 +2130,7 @@ function renderDiff(diff) {
2108
2130
 
2109
2131
  // src/content-files.ts
2110
2132
  import { readFile as readFile2, readdir, stat } from "fs/promises";
2111
- import { basename as basename2, extname, join as join5 } from "path";
2133
+ import { basename as basename2, extname, join as join6 } from "path";
2112
2134
  async function detectFormat(path) {
2113
2135
  const info = await stat(path).catch(() => void 0);
2114
2136
  if (!info) throw new UsageError(`No such file or directory: ${path}`);
@@ -2186,7 +2208,7 @@ async function readJsonRows(file) {
2186
2208
  }
2187
2209
  async function readMarkdownRows(dir, bodyField) {
2188
2210
  const info = await stat(dir).catch(() => void 0);
2189
- const files = info?.isDirectory() ? (await readdir(dir)).filter((name) => [".md", ".markdown"].includes(extname(name).toLowerCase())).sort().map((name) => join5(dir, name)) : [dir];
2211
+ const files = info?.isDirectory() ? (await readdir(dir)).filter((name) => [".md", ".markdown"].includes(extname(name).toLowerCase())).sort().map((name) => join6(dir, name)) : [dir];
2190
2212
  if (files.length === 0) throw new UsageError(`No .md files found in ${dir}`);
2191
2213
  const rows = [];
2192
2214
  for (const file of files) {
@@ -2823,6 +2845,25 @@ function registerReleases(program) {
2823
2845
  });
2824
2846
  })
2825
2847
  );
2848
+ releases.command("diff").description("Show what a release changed, field by field").argument("<release>", "release number or published change set id").action(
2849
+ handle(async (ctx, args) => {
2850
+ const project = ctx.requireProject();
2851
+ const release = await ctx.management().releases.get(project, args[0]);
2852
+ const diff = await ctx.management().changeSets.diff(project, release.id);
2853
+ emit({ release, ...diff }, () => {
2854
+ diag(`#${release.number} \u2014 ${release.title} (${diff.items.length} item(s))`);
2855
+ for (const item of diff.items) {
2856
+ const name = item.collection && item.slug ? `${item.collection}/${item.slug}` : item.resourceId;
2857
+ process.stdout.write(` ${item.operation} ${name}
2858
+ `);
2859
+ for (const change of item.changes) {
2860
+ process.stdout.write(` ${change.kind} ${change.path}
2861
+ `);
2862
+ }
2863
+ }
2864
+ });
2865
+ })
2866
+ );
2826
2867
  releases.command("revert").description("Generate a change set restoring the state before a release").argument("<release>", "release number or published change set id").option("--title <title>", "title for the generated change set").option("--force", "revert resources a later release already changed", false).action(
2827
2868
  handle(async (ctx, args, opts) => {
2828
2869
  const project = ctx.requireProject();
@@ -2854,6 +2895,244 @@ function registerReleases(program) {
2854
2895
  );
2855
2896
  }
2856
2897
 
2898
+ // src/commands/pull.ts
2899
+ import { execSync } from "child_process";
2900
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
2901
+ import { join as join7 } from "path";
2902
+
2903
+ // ../sdk/dist/lock.js
2904
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
2905
+ var LOCKFILE_NAME = "myna.lock";
2906
+ function parseLock(source, origin = LOCKFILE_NAME) {
2907
+ let parsed;
2908
+ try {
2909
+ parsed = JSON.parse(source);
2910
+ } catch (error) {
2911
+ throw new Error(`${origin} is not valid JSON: ${error.message}`);
2912
+ }
2913
+ const lock = parsed;
2914
+ if (typeof lock?.project !== "string" || !lock.project) {
2915
+ throw new Error(`${origin} is missing "project".`);
2916
+ }
2917
+ if (typeof lock.release !== "number" || !Number.isInteger(lock.release) || lock.release < 0) {
2918
+ throw new Error(`${origin} is missing a non-negative integer "release".`);
2919
+ }
2920
+ return {
2921
+ project: lock.project,
2922
+ release: lock.release,
2923
+ publishedAt: typeof lock.publishedAt === "string" ? lock.publishedAt : "",
2924
+ ...lock.title ? { title: lock.title } : {},
2925
+ ...lock.apiUrl ? { apiUrl: lock.apiUrl } : {}
2926
+ };
2927
+ }
2928
+ function formatLock(lock) {
2929
+ const ordered = {
2930
+ project: lock.project,
2931
+ release: lock.release,
2932
+ publishedAt: lock.publishedAt
2933
+ };
2934
+ if (lock.title) ordered.title = lock.title;
2935
+ if (lock.apiUrl) ordered.apiUrl = lock.apiUrl;
2936
+ return `${JSON.stringify(ordered, null, 2)}
2937
+ `;
2938
+ }
2939
+ function writeLock(file, lock) {
2940
+ writeFileSync4(file, formatLock(lock));
2941
+ }
2942
+
2943
+ // src/commands/pull.ts
2944
+ var MAX_DETAILED_RELEASES = 20;
2945
+ function lockPath(ctx) {
2946
+ return join7(ctx.linkedRoot ?? process.cwd(), LOCKFILE_NAME);
2947
+ }
2948
+ function readLockAt(file) {
2949
+ if (!existsSync6(file)) return void 0;
2950
+ return parseLock(readFileSync5(file, "utf8"), file);
2951
+ }
2952
+ async function latestRelease(ctx, project) {
2953
+ const page = await ctx.management().releases.list(project, { limit: 1 });
2954
+ const latest = page.data[0];
2955
+ if (!latest) {
2956
+ throw new CliError(
2957
+ `Project ${project} has not published a release yet. There is nothing to pin \u2014 publish a change set first.`
2958
+ );
2959
+ }
2960
+ return latest;
2961
+ }
2962
+ async function releasesBetween(ctx, project, from, to) {
2963
+ const found = [];
2964
+ let cursor;
2965
+ for (; ; ) {
2966
+ const page = await ctx.management().releases.list(project, { limit: 50, ...cursor ? { cursor } : {} });
2967
+ for (const release of page.data) {
2968
+ if (release.number > to) continue;
2969
+ if (release.number <= from) return found.reverse();
2970
+ found.push(release);
2971
+ }
2972
+ if (!page.nextCursor) return found.reverse();
2973
+ cursor = page.nextCursor;
2974
+ }
2975
+ }
2976
+ function itemLabel(item) {
2977
+ const name = item.collection && item.slug ? `${item.collection}/${item.slug}` : item.resourceId;
2978
+ return `${item.operation} ${name}`;
2979
+ }
2980
+ async function pullRequestBody(ctx, project, from, to, releases, rollback) {
2981
+ const lines = [
2982
+ !from ? `Pins \`${LOCKFILE_NAME}\` for \`${project}\` to release #${to.number}.` : rollback ? `Rolls \`${LOCKFILE_NAME}\` for \`${project}\` back from release #${from.release} to #${to.number}.` : `Moves \`${LOCKFILE_NAME}\` for \`${project}\` from release #${from.release} to #${to.number}.`,
2983
+ "",
2984
+ rollback ? `${releases.length} release(s) are being undone for this repository. The content itself is untouched \u2014 nothing is unpublished \u2014 this only changes which release the build reads.` : `${releases.length} release(s). The build reads content at this release, so CI on this pull request is running against exactly the content that will ship.`
2985
+ ];
2986
+ const detailed = releases.slice(-MAX_DETAILED_RELEASES);
2987
+ if (detailed.length < releases.length) {
2988
+ lines.push(
2989
+ "",
2990
+ `_Showing the newest ${detailed.length} of ${releases.length} releases. The remaining ${releases.length - detailed.length} are listed without field detail._`,
2991
+ "",
2992
+ ...releases.slice(0, releases.length - detailed.length).map((r) => `- #${r.number} \u2014 ${r.title} (${r.itemCount} item(s))`)
2993
+ );
2994
+ }
2995
+ for (const release of detailed) {
2996
+ lines.push("", `### #${release.number} \u2014 ${release.title}`);
2997
+ if (release.description) lines.push("", release.description);
2998
+ let diff;
2999
+ try {
3000
+ diff = await ctx.management().changeSets.diff(project, release.id);
3001
+ } catch {
3002
+ lines.push("", `_Diff unavailable; ${release.itemCount} item(s) changed._`);
3003
+ continue;
3004
+ }
3005
+ for (const item of diff.items) {
3006
+ lines.push("", `- **${itemLabel(item)}**`);
3007
+ if (item.changeSummary) lines.push(` - _${item.changeSummary}_`);
3008
+ for (const change of item.changes) lines.push(` - \`${change.path}\` ${change.kind}`);
3009
+ }
3010
+ }
3011
+ return lines.join("\n");
3012
+ }
3013
+ function registerPull(program) {
3014
+ program.command("pull").description(`Pin this repository's content to a release in ${LOCKFILE_NAME}`).option("--release <n>", "release number to pin to (default: the newest)").option("--check", "report whether the pin is behind and exit non-zero if it is", false).option("--open-pr", "commit the bump onto a branch and open a pull request", false).option(
3015
+ "--run <command>",
3016
+ "shell command to run after moving the pin; anything it changes is committed with the lockfile"
3017
+ ).option("--branch <name>", "branch to use with --open-pr (default: derived from the release)").option("--base <branch>", "branch to open the pull request against (default: the current branch)").action(
3018
+ handle(async (ctx, _args, opts) => {
3019
+ const project = ctx.requireProject();
3020
+ const file = lockPath(ctx);
3021
+ const current = readLockAt(file);
3022
+ if (current && current.project !== project) {
3023
+ throw new UsageError(
3024
+ `${LOCKFILE_NAME} pins project "${current.project}" but the selected project is "${project}". Pass --project ${current.project}, or delete the lockfile.`
3025
+ );
3026
+ }
3027
+ const target = opts.release ? await ctx.management().releases.get(project, String(opts.release)) : await latestRelease(ctx, project);
3028
+ if (opts.check) {
3029
+ if (!current) {
3030
+ throw new UsageError(`No ${LOCKFILE_NAME} here. Run \`myna pull\` to create one.`);
3031
+ }
3032
+ const behind = await releasesBetween(ctx, project, current.release, target.number);
3033
+ emit(
3034
+ { lockfile: file, release: current.release, latest: target.number, behind: behind.map((r) => r.number) },
3035
+ () => {
3036
+ if (behind.length === 0) {
3037
+ diag(`${LOCKFILE_NAME} is at release #${current.release} \u2014 current.`);
3038
+ return;
3039
+ }
3040
+ diag(`${LOCKFILE_NAME} is at release #${current.release}; #${target.number} is available.`);
3041
+ table(behind, [
3042
+ { header: "#", value: (r) => String(r.number) },
3043
+ { header: "TITLE", value: (r) => r.title },
3044
+ { header: "PUBLISHED", value: (r) => r.publishedAt },
3045
+ { header: "ITEMS", value: (r) => String(r.itemCount) }
3046
+ ]);
3047
+ }
3048
+ );
3049
+ if (behind.length > 0) process.exitCode = 1;
3050
+ return;
3051
+ }
3052
+ const next = {
3053
+ project,
3054
+ release: target.number,
3055
+ publishedAt: target.publishedAt,
3056
+ title: target.title,
3057
+ ...ctx.apiUrl && !ctx.apiUrl.startsWith("https://api.myna.sh") ? { apiUrl: ctx.apiUrl } : {}
3058
+ };
3059
+ if (current && formatLock(current) === formatLock(next)) {
3060
+ emit(
3061
+ { lockfile: file, release: target.number, changed: false, pullRequest: null },
3062
+ () => diag(`${LOCKFILE_NAME} is already at release #${target.number}.`)
3063
+ );
3064
+ return;
3065
+ }
3066
+ const rollback = current !== void 0 && target.number < current.release;
3067
+ const releases = rollback ? await releasesBetween(ctx, project, target.number, current.release) : await releasesBetween(ctx, project, current?.release ?? 0, target.number);
3068
+ if (!opts.openPr) {
3069
+ writeLock(file, next);
3070
+ if (opts.run) execSync(opts.run, { cwd: ctx.linkedRoot ?? process.cwd(), stdio: "inherit" });
3071
+ emit(
3072
+ {
3073
+ lockfile: file,
3074
+ release: target.number,
3075
+ from: current?.release ?? null,
3076
+ changed: true,
3077
+ rollback,
3078
+ releases: releases.map((r) => r.number)
3079
+ },
3080
+ () => {
3081
+ if (!current) {
3082
+ diag(`${LOCKFILE_NAME}: pinned to #${target.number}.`);
3083
+ return;
3084
+ }
3085
+ diag(
3086
+ rollback ? `${LOCKFILE_NAME}: #${current.release} \u2192 #${target.number}, rolling back ${releases.length} release(s).` : `${LOCKFILE_NAME}: #${current.release} \u2192 #${target.number} (${releases.length} release(s)).`
3087
+ );
3088
+ }
3089
+ );
3090
+ return;
3091
+ }
3092
+ const root = ctx.linkedRoot ?? process.cwd();
3093
+ const command = opts.run;
3094
+ const dirty = command ? worktreeChanges(root).map((c) => c.file) : dirtyFiles(root, [file]);
3095
+ if (dirty.length > 0) {
3096
+ throw new UsageError(`Commit or stash ${dirty.join(", ")} first.`);
3097
+ }
3098
+ const body = await pullRequestBody(ctx, project, current, target, releases, rollback);
3099
+ writeLock(file, next);
3100
+ if (command) {
3101
+ try {
3102
+ execSync(command, { cwd: root, stdio: "inherit" });
3103
+ } catch (error) {
3104
+ discardChanges(root, worktreeChanges(root));
3105
+ throw new CliError(`--run command failed: ${error.message}`);
3106
+ }
3107
+ }
3108
+ const changes = worktreeChanges(root);
3109
+ const branch = opts.branch ?? branchForRelease(project, target.number);
3110
+ let result;
3111
+ try {
3112
+ result = openPullRequest({
3113
+ cwd: root,
3114
+ files: changes.map((c) => c.file),
3115
+ branch,
3116
+ base: opts.base,
3117
+ title: rollback ? `chore(content): roll ${project} back to release #${target.number}` : `chore(content): ${project} release #${target.number} \u2014 ${target.title}`,
3118
+ body
3119
+ });
3120
+ } catch (error) {
3121
+ discardChanges(root, changes);
3122
+ throw error;
3123
+ }
3124
+ if (!result.opened) discardChanges(root, changes);
3125
+ emit({ lockfile: file, release: target.number, from: current?.release ?? null, releases: releases.map((r) => r.number), pullRequest: result }, () => {
3126
+ if (!result.opened) {
3127
+ diag(`Branch ${result.branch} already exists \u2014 release #${target.number} is already proposed.`);
3128
+ return;
3129
+ }
3130
+ diag(`Opened ${result.url ?? `pull request from ${result.branch}`} against ${result.base}.`);
3131
+ });
3132
+ })
3133
+ );
3134
+ }
3135
+
2857
3136
  // src/commands/previews.ts
2858
3137
  function previewTarget(ref) {
2859
3138
  if (ref.startsWith("chs_")) return { changeSetId: ref };
@@ -3698,8 +3977,8 @@ function registerBilling(program) {
3698
3977
  }
3699
3978
 
3700
3979
  // src/commands/doctor.ts
3701
- import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
3702
- import { join as join6 } from "path";
3980
+ import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
3981
+ import { join as join8 } from "path";
3703
3982
 
3704
3983
  // src/registry.ts
3705
3984
  var NPM_REGISTRY = "https://registry.npmjs.org";
@@ -3738,7 +4017,7 @@ function installCommand(manager, version) {
3738
4017
  }
3739
4018
 
3740
4019
  // src/version.ts
3741
- var VERSION = true ? "0.11.0" : "0.0.0-dev";
4020
+ var VERSION = true ? "0.12.0" : "0.0.0-dev";
3742
4021
  var IS_RELEASE_BUILD = true;
3743
4022
 
3744
4023
  // src/commands/doctor.ts
@@ -3912,7 +4191,7 @@ async function checkOrigin(ctx, origin) {
3912
4191
  }
3913
4192
  async function checkSchema(ctx, schemaDir) {
3914
4193
  const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
3915
- if (!existsSync5(dir)) {
4194
+ if (!existsSync7(dir)) {
3916
4195
  return check("schema.drift", "Local schema", "skip", `No schema directory at ${dir}.`);
3917
4196
  }
3918
4197
  if (!ctx.project) {
@@ -3962,7 +4241,7 @@ function findGeneratedTypes(root, depth = 4) {
3962
4241
  const dirs = [];
3963
4242
  for (const entry of entries) {
3964
4243
  if (SCAN_IGNORE.has(entry) || entry.startsWith(".")) continue;
3965
- const full = join6(root, entry);
4244
+ const full = join8(root, entry);
3966
4245
  let stats;
3967
4246
  try {
3968
4247
  stats = statSync2(full);
@@ -3975,7 +4254,7 @@ function findGeneratedTypes(root, depth = 4) {
3975
4254
  }
3976
4255
  if (!/\.(ts|d\.ts)$/.test(entry)) continue;
3977
4256
  try {
3978
- if (looksGenerated(readFileSync4(full, "utf8"))) return full;
4257
+ if (looksGenerated(readFileSync6(full, "utf8"))) return full;
3979
4258
  } catch {
3980
4259
  }
3981
4260
  }
@@ -3992,16 +4271,16 @@ async function checkTypes(ctx, explicit, schemaDir) {
3992
4271
  if (!file) {
3993
4272
  return check("types.freshness", "Generated types", "skip", "No generated types file found.");
3994
4273
  }
3995
- if (!existsSync5(file)) {
4274
+ if (!existsSync7(file)) {
3996
4275
  return check("types.freshness", "Generated types", "fail", `${file} does not exist.`);
3997
4276
  }
3998
4277
  const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
3999
- if (!existsSync5(dir)) {
4278
+ if (!existsSync7(dir)) {
4000
4279
  return check("types.freshness", "Generated types", "skip", `Found ${file} but no schema directory to compare against.`);
4001
4280
  }
4002
4281
  try {
4003
4282
  const expected = generateTypesModule(await loadLocalSchemas(dir));
4004
- return readFileSync4(file, "utf8") === expected ? check("types.freshness", "Generated types", "pass", `${file} matches the local schema.`) : check(
4283
+ return readFileSync6(file, "utf8") === expected ? check("types.freshness", "Generated types", "pass", `${file} matches the local schema.`) : check(
4005
4284
  "types.freshness",
4006
4285
  "Generated types",
4007
4286
  "warn",
@@ -4117,7 +4396,7 @@ function registerUpdate(program) {
4117
4396
 
4118
4397
  // src/commands/sync.ts
4119
4398
  import { readdir as readdir2, stat as stat2 } from "fs/promises";
4120
- import { join as join7 } from "path";
4399
+ import { join as join9 } from "path";
4121
4400
  async function isDirectory(path) {
4122
4401
  const info = await stat2(path).catch(() => void 0);
4123
4402
  return Boolean(info?.isDirectory());
@@ -4132,7 +4411,7 @@ async function planDirectories(dir, collection) {
4132
4411
  const plan = [];
4133
4412
  for (const name of children.sort()) {
4134
4413
  if (name.startsWith(".")) continue;
4135
- const full = join7(dir, name);
4414
+ const full = join9(dir, name);
4136
4415
  if (await isDirectory(full)) plan.push({ collection: name, path: full });
4137
4416
  }
4138
4417
  if (plan.length === 0) {
@@ -4303,6 +4582,7 @@ function buildProgram() {
4303
4582
  registerSync(program);
4304
4583
  registerChanges(program);
4305
4584
  registerReleases(program);
4585
+ registerPull(program);
4306
4586
  registerPreviews(program);
4307
4587
  registerAssets(program);
4308
4588
  registerAdmin(program);