@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.3
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/akanConfig/akanConfig.test.ts +51 -5
- package/akanConfig/akanConfig.ts +31 -12
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/applicationBuildRunner.test.ts +33 -1
- package/applicationBuildRunner.ts +4 -5
- package/cloud/cloudApi.ts +22 -15
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +8 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +100 -45
- package/cloud/updatePackUpload.ts +38 -18
- package/commandDecorators/argMeta.ts +11 -2
- package/commandDecorators/command.ts +40 -6
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -0
- package/executors.test.ts +69 -0
- package/executors.ts +30 -27
- package/frontendBuild/csrArtifactBuilder.ts +18 -1
- package/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- package/mobile/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -33
- package/mobile/nativeApp.ts +109 -73
- package/mobile/nativeConfig.ts +2 -1
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +114 -22
- package/mobile/updateUploader.ts +75 -8
- package/package.json +2 -2
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/qualityScanner.test.ts +5 -3
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +361 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- package/transforms/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- package/typecheck/projectTypecheck.ts +6 -5
package/subspace.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { mkdir
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { toError } from "akanjs/common";
|
|
4
|
+
import { AppExecutor, type WorkspaceExecutor } from "./executors";
|
|
4
5
|
import { FileSys } from "./fileSys";
|
|
5
6
|
import { LibSource } from "./libSource";
|
|
6
|
-
import {
|
|
7
|
+
import { SlicePlanner } from "./slicePlanner";
|
|
8
|
+
import { SubspaceClone } from "./subspaceClone";
|
|
7
9
|
import type { SubspaceConfig, SubspaceDeclaration } from "./subspaceConfig";
|
|
8
|
-
import type { PackageJson } from "./types";
|
|
9
10
|
|
|
10
11
|
export interface SubspaceIncomingCommit {
|
|
11
12
|
sha: string;
|
|
@@ -59,8 +60,8 @@ export interface SubspacePlan {
|
|
|
59
60
|
name: string;
|
|
60
61
|
repo: string;
|
|
61
62
|
branch: string;
|
|
62
|
-
/** Why a push from this branch would be refused, or null when it may push. */
|
|
63
63
|
branchRefusal: string | null;
|
|
64
|
+
workspaceRefusal: string | null;
|
|
64
65
|
apps: string[];
|
|
65
66
|
/** Each library the slice carries, with the apps whose closure pulls it in. */
|
|
66
67
|
libs: { name: string; apps: string[] }[];
|
|
@@ -72,7 +73,6 @@ export interface SubspacePlan {
|
|
|
72
73
|
/** Workspace-root entries never pushed: the subspace keeps its own copy, if it has one. */
|
|
73
74
|
subspaceOwnedRootEntries: string[];
|
|
74
75
|
prunedDependencies: string[];
|
|
75
|
-
/** Why the workspace's bun.lock would make a push refuse, or null. */
|
|
76
76
|
lockProblem: string | null;
|
|
77
77
|
warnings: string[];
|
|
78
78
|
}
|
|
@@ -100,19 +100,10 @@ export interface SubspacePullResult {
|
|
|
100
100
|
// Push is a squashed snapshot, so no workspace history (or other customer's commit message) reaches a subspace;
|
|
101
101
|
// pull diffs against the last push received, so it is exactly the customer's own work.
|
|
102
102
|
export class Subspace {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
*/
|
|
108
|
-
static readonly subspaceOwnedRootEntries = ["akan.config.ts", "Jenkinsfile"];
|
|
109
|
-
static readonly syncSubjectPrefix = "chore(subspace): sync from ";
|
|
110
|
-
/** Workspace members, which are replaced wholesale rather than overlaid. */
|
|
111
|
-
static readonly memberDirs = ["apps", "libs"];
|
|
112
|
-
/** Subspace-owned in both directions: env values belong to the repo that deploys, not to the workspace. */
|
|
113
|
-
static readonly subspaceOwnedDirs = ["env"];
|
|
114
|
-
static readonly secretsMarkers = ["# akan:secrets (managed by akan.config.ts — do not edit)", "# akan:secrets:end"];
|
|
115
|
-
static readonly holdDir = ".akan-subspace-hold";
|
|
103
|
+
//* The declaration file before `akan.config.ts`: it names every customer's repo, so a push removes any copy of it.
|
|
104
|
+
static readonly retiredConfigFile = "akan.subspace.ts";
|
|
105
|
+
static readonly #retiredConfigRefusal =
|
|
106
|
+
`${Subspace.retiredConfigFile} is no longer read: move it under \`subspace\` in akan.config.ts and delete it`;
|
|
116
107
|
static readonly #loopbackRegistry = /https?:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\])(?::\d+)?/;
|
|
117
108
|
|
|
118
109
|
#workspace: WorkspaceExecutor;
|
|
@@ -137,18 +128,11 @@ export class Subspace {
|
|
|
137
128
|
get #remote() {
|
|
138
129
|
return `subspace-${this.#declaration.name}`;
|
|
139
130
|
}
|
|
140
|
-
get #clonePath() {
|
|
141
|
-
return path.join(this.#workspace.workspaceRoot, ".akan/subspace", this.#declaration.name);
|
|
142
|
-
}
|
|
143
131
|
|
|
144
132
|
async #git(args: string[]) {
|
|
145
133
|
return await this.#workspace.spawn("git", args);
|
|
146
134
|
}
|
|
147
135
|
|
|
148
|
-
async #cloneGit(args: string[]) {
|
|
149
|
-
return await this.#workspace.spawn("git", ["-C", this.#clonePath, ...args]);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
136
|
async branch() {
|
|
153
137
|
return (await this.#git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
154
138
|
}
|
|
@@ -173,7 +157,7 @@ export class Subspace {
|
|
|
173
157
|
//* Located by the one file only a push writes: unlike a tag or a recorded sha, it cannot drift out of the history.
|
|
174
158
|
async anchor(branch: string): Promise<SubspaceAnchor> {
|
|
175
159
|
const ref = `${this.#remote}/${branch}`;
|
|
176
|
-
const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--",
|
|
160
|
+
const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", SubspaceClone.anchorFile])).trim();
|
|
177
161
|
if (!commit) return { commit: null, incoming: [] };
|
|
178
162
|
const log = await this.#git(["log", "--format=%H%x09%an%x09%s", `${commit}..${ref}`]);
|
|
179
163
|
const incoming = log
|
|
@@ -200,10 +184,11 @@ export class Subspace {
|
|
|
200
184
|
|
|
201
185
|
/** What a push from `branch` would ship, worked out from this checkout alone: no fetch, no remote, no clone. */
|
|
202
186
|
async plan(branch: string): Promise<SubspacePlan> {
|
|
187
|
+
const workspaceRefusal = await this.#workspaceRefusal();
|
|
203
188
|
const { plans, libs } = await this.slice();
|
|
204
189
|
const rootFiles = plans[0]?.rootFiles ?? [];
|
|
205
|
-
const present = new Set(
|
|
206
|
-
const owned =
|
|
190
|
+
const present = new Set(SlicePlanner.rootEntriesOf(rootFiles));
|
|
191
|
+
const owned = SubspaceClone.subspaceOwnedRootEntries;
|
|
207
192
|
const excluded = this.#config.exclude.filter((entry) => !owned.includes(entry));
|
|
208
193
|
const excludedTops = new Set(excluded.map((entry) => entry.replace(/\/+$/, "")));
|
|
209
194
|
const libFiles = new Map(plans.flatMap((plan) => Object.entries(plan.libFiles)));
|
|
@@ -216,13 +201,14 @@ export class Subspace {
|
|
|
216
201
|
try {
|
|
217
202
|
this.#config.assertPushable(branch);
|
|
218
203
|
} catch (error) {
|
|
219
|
-
branchRefusal = error
|
|
204
|
+
branchRefusal = toError(error).message;
|
|
220
205
|
}
|
|
221
206
|
return {
|
|
222
207
|
name: this.name,
|
|
223
208
|
repo: this.#declaration.repo,
|
|
224
209
|
branch,
|
|
225
210
|
branchRefusal,
|
|
211
|
+
workspaceRefusal,
|
|
226
212
|
apps: [...this.#declaration.apps],
|
|
227
213
|
libs: libs.map((lib) => ({
|
|
228
214
|
name: lib,
|
|
@@ -232,22 +218,27 @@ export class Subspace {
|
|
|
232
218
|
plans.reduce((sum, plan) => sum + plan.appFiles.length, 0) +
|
|
233
219
|
[...libFiles.values()].reduce((sum, files) => sum + files.length, 0),
|
|
234
220
|
rootEntries: [...present]
|
|
235
|
-
.filter((entry) => !excludedTops.has(entry) && !owned.includes(entry))
|
|
221
|
+
.filter((entry) => !excludedTops.has(entry) && !owned.includes(entry) && entry !== Subspace.retiredConfigFile)
|
|
236
222
|
.sort((a, b) => a.localeCompare(b)),
|
|
237
223
|
excludedRootEntries: excluded,
|
|
238
224
|
subspaceOwnedRootEntries: owned.filter((entry) => present.has(entry)),
|
|
239
225
|
prunedDependencies: pruned,
|
|
240
|
-
lockProblem:
|
|
241
|
-
? await this.#loopbackRegistryLock(this.#workspace.workspaceRoot)
|
|
242
|
-
: null,
|
|
226
|
+
lockProblem: await this.#headLockProblem(),
|
|
243
227
|
warnings: [...new Set([...plans.flatMap((plan) => plan.warnings), ...warnings])],
|
|
244
228
|
};
|
|
245
229
|
}
|
|
246
230
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
231
|
+
static planBlockers(plan: SubspacePlan) {
|
|
232
|
+
return [plan.branchRefusal, plan.workspaceRefusal, plan.lockProblem].filter((reason): reason is string => !!reason);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async #retiredConfigLeft() {
|
|
236
|
+
return await FileSys.fileExists(path.join(this.#workspace.workspaceRoot, Subspace.retiredConfigFile));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async #workspaceRefusal() {
|
|
240
|
+
if (await this.#retiredConfigLeft()) return Subspace.#retiredConfigRefusal;
|
|
241
|
+
return (await this.#workspace.hasChanges()) ? "workspace working tree is dirty" : null;
|
|
251
242
|
}
|
|
252
243
|
|
|
253
244
|
// A library manifest always differs by the `akan.source` stamp a push writes, so it is compared without that key.
|
|
@@ -274,7 +265,7 @@ export class Subspace {
|
|
|
274
265
|
async #changedSliceFiles(ref: string, diffArgs: string[]) {
|
|
275
266
|
const files = (await this.#git(["diff", "--name-only", ...diffArgs]))
|
|
276
267
|
.split("\n")
|
|
277
|
-
.filter((file) => !!file.trim() && !
|
|
268
|
+
.filter((file) => !!file.trim() && !SubspaceClone.isSubspaceOwned(file));
|
|
278
269
|
const kept = await Promise.all(
|
|
279
270
|
files.map(async (file) => (this.#isStamped(file) ? await this.#differsBeyondStamp(ref, file) : true)),
|
|
280
271
|
);
|
|
@@ -294,272 +285,6 @@ export class Subspace {
|
|
|
294
285
|
return { name: this.name, branch, hasBranch, anchor, behindPaths, driftedLibs };
|
|
295
286
|
}
|
|
296
287
|
|
|
297
|
-
async #ensureClone(branch: string) {
|
|
298
|
-
if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
|
|
299
|
-
await rm(this.#clonePath, { recursive: true, force: true });
|
|
300
|
-
await mkdir(path.dirname(this.#clonePath), { recursive: true });
|
|
301
|
-
await this.#workspace.spawn("git", ["clone", "--quiet", this.#declaration.repo, this.#clonePath]);
|
|
302
|
-
}
|
|
303
|
-
const clone = new Executor(`subspace-${this.name}`, this.#clonePath);
|
|
304
|
-
await clone.spawn("git", ["fetch", "--quiet", "origin", branch]);
|
|
305
|
-
await clone.spawn("git", ["checkout", "--quiet", "-f", "-B", branch, `origin/${branch}`]);
|
|
306
|
-
await clone.spawn("git", ["clean", "-qfd"]);
|
|
307
|
-
return clone;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** Moves the subspace's env trees aside, so replacing the members cannot take them with it. */
|
|
311
|
-
async #holdSubspaceOwned() {
|
|
312
|
-
const held: { from: string; to: string }[] = [];
|
|
313
|
-
const holdRoot = path.join(this.#clonePath, Subspace.holdDir);
|
|
314
|
-
await rm(holdRoot, { recursive: true, force: true });
|
|
315
|
-
for (const member of Subspace.memberDirs) {
|
|
316
|
-
const memberRoot = path.join(this.#clonePath, member);
|
|
317
|
-
if (!(await FileSys.dirExists(memberRoot))) continue;
|
|
318
|
-
for (const name of await readdir(memberRoot)) {
|
|
319
|
-
for (const owned of Subspace.subspaceOwnedDirs) {
|
|
320
|
-
const from = path.join(memberRoot, name, owned);
|
|
321
|
-
if (!(await FileSys.dirExists(from))) continue;
|
|
322
|
-
const to = path.join(holdRoot, member, name, owned);
|
|
323
|
-
await mkdir(path.dirname(to), { recursive: true });
|
|
324
|
-
await rename(from, to);
|
|
325
|
-
held.push({ from, to });
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
return held;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// The workspace owns the tracked env switch files; the per-environment values it gitignores belong to the subspace.
|
|
333
|
-
async #restoreSubspaceOwned(held: { from: string; to: string }[]) {
|
|
334
|
-
for (const { from, to } of held) await Subspace.#copyMissing(to, from);
|
|
335
|
-
await rm(path.join(this.#clonePath, Subspace.holdDir), { recursive: true, force: true });
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
//* A root entry last written by a sync commit is a copy an earlier push left there, the parent's Jenkinsfile among them:
|
|
339
|
-
//* it goes. Any other is the subspace's own and is held through the extraction.
|
|
340
|
-
async #holdRootEntries() {
|
|
341
|
-
const holdRoot = path.join(this.#clonePath, Subspace.holdDir, "root");
|
|
342
|
-
const held: string[] = [];
|
|
343
|
-
for (const entry of Subspace.subspaceOwnedRootEntries) {
|
|
344
|
-
const from = path.join(this.#clonePath, entry);
|
|
345
|
-
if (!(await FileSys.entryExists(from))) continue;
|
|
346
|
-
if (await this.#isPushedCopy(entry)) continue;
|
|
347
|
-
await mkdir(holdRoot, { recursive: true });
|
|
348
|
-
await rename(from, path.join(holdRoot, entry));
|
|
349
|
-
held.push(entry);
|
|
350
|
-
}
|
|
351
|
-
return held;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async #restoreRootEntries(held: string[]) {
|
|
355
|
-
const holdRoot = path.join(this.#clonePath, Subspace.holdDir, "root");
|
|
356
|
-
for (const entry of Subspace.subspaceOwnedRootEntries) {
|
|
357
|
-
const target = path.join(this.#clonePath, entry);
|
|
358
|
-
await rm(target, { recursive: true, force: true });
|
|
359
|
-
if (held.includes(entry)) await rename(path.join(holdRoot, entry), target);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async #isPushedCopy(entry: string) {
|
|
364
|
-
const subject = await this.#cloneGit(["log", "-1", "--format=%s", "--", entry]).catch(() => "");
|
|
365
|
-
return subject.trim().startsWith(Subspace.syncSubjectPrefix);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
static async #copyMissing(from: string, to: string) {
|
|
369
|
-
await mkdir(to, { recursive: true });
|
|
370
|
-
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
371
|
-
const source = path.join(from, entry.name);
|
|
372
|
-
const target = path.join(to, entry.name);
|
|
373
|
-
if (entry.isDirectory()) await Subspace.#copyMissing(source, target);
|
|
374
|
-
else if (!(await FileSys.entryExists(target))) await rename(source, target);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// `git archive` emits only tracked files, so generated barrels, lib symlinks, env values and secrets never enter.
|
|
379
|
-
async #extract(paths: string[], excludes: string[] = []) {
|
|
380
|
-
// Relative, because GNU tar reads a drive-letter archive name (`C:\…`) as `host:path` on a remote host.
|
|
381
|
-
const archivePath = path.relative(this.#workspace.workspaceRoot, `${this.#clonePath}.tar`);
|
|
382
|
-
await this.#workspace.spawn("git", ["archive", "-o", archivePath, "HEAD", "--", ...paths]);
|
|
383
|
-
try {
|
|
384
|
-
await this.#workspace.spawn("tar", [
|
|
385
|
-
"-x",
|
|
386
|
-
"-f",
|
|
387
|
-
archivePath,
|
|
388
|
-
"-C",
|
|
389
|
-
this.#clonePath,
|
|
390
|
-
...excludes.map((entry) => `--exclude=${entry}`),
|
|
391
|
-
]);
|
|
392
|
-
} finally {
|
|
393
|
-
await rm(path.join(this.#workspace.workspaceRoot, archivePath), { force: true });
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// An allowlist, not a filter, so no secret can reach a customer's clone; `AKAN_PUBLIC_*` ships in every bundle anyway.
|
|
398
|
-
#localEnv() {
|
|
399
|
-
const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
|
|
400
|
-
return {
|
|
401
|
-
AKAN_WORKSPACE_ID: "local",
|
|
402
|
-
AKAN_PUBLIC_REPO_NAME: this.name,
|
|
403
|
-
AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
|
|
404
|
-
AKAN_PUBLIC_ENV: "local",
|
|
405
|
-
AKAN_PUBLIC_OPERATION_MODE: "local",
|
|
406
|
-
AKAN_PUBLIC_LOG_LEVEL: "warn",
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// The CLI needs a `.env` to recognise a workspace root and none arrives with the slice. Excluded through
|
|
411
|
-
// `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
|
|
412
|
-
async #writeLocalEnv() {
|
|
413
|
-
const excludePath = path.join(this.#clonePath, ".git/info/exclude");
|
|
414
|
-
const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
|
|
415
|
-
if (!exclude.split("\n").includes("/.env")) {
|
|
416
|
-
await mkdir(path.dirname(excludePath), { recursive: true });
|
|
417
|
-
await FileSys.writeText(excludePath, exclude.trim() ? `${exclude.replace(/\n*$/, "\n")}/.env\n` : "/.env\n");
|
|
418
|
-
}
|
|
419
|
-
const envPath = path.join(this.#clonePath, ".env");
|
|
420
|
-
if (await FileSys.fileExists(envPath)) return;
|
|
421
|
-
const lines = Object.entries(this.#localEnv()).map(([key, value]) => `${key}=${value}`);
|
|
422
|
-
await FileSys.writeText(envPath, `${lines.join("\n")}\n`);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// The root manifest is the union of every app's dependencies, so it is rebuilt from this subspace's slices.
|
|
426
|
-
async #rewriteManifest(plans: SlicePlan[]) {
|
|
427
|
-
const manifestPath = path.join(this.#clonePath, "package.json");
|
|
428
|
-
if (!(await FileSys.fileExists(manifestPath))) return [];
|
|
429
|
-
const rootPackageJson = (await FileSys.readJson(manifestPath)) as PackageJson;
|
|
430
|
-
const { packageJson, pruned, warnings } = await SlicePlanner.pruneDependencies(
|
|
431
|
-
this.#workspace,
|
|
432
|
-
rootPackageJson,
|
|
433
|
-
plans.flatMap((plan) => plan.requiredDependencies),
|
|
434
|
-
);
|
|
435
|
-
for (const warning of warnings) this.#workspace.logger.warn(warning);
|
|
436
|
-
await FileSys.writeJson(manifestPath, {
|
|
437
|
-
...packageJson,
|
|
438
|
-
name: this.name,
|
|
439
|
-
description: `${this.name} workspace`,
|
|
440
|
-
});
|
|
441
|
-
return pruned;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
async #applySlice(clone: Executor, branch: string) {
|
|
445
|
-
const { libs, paths, plans } = await this.slice();
|
|
446
|
-
const held = await this.#holdSubspaceOwned();
|
|
447
|
-
const heldRootEntries = await this.#holdRootEntries();
|
|
448
|
-
for (const member of Subspace.memberDirs)
|
|
449
|
-
await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
|
|
450
|
-
|
|
451
|
-
//* Overlaid, never reconciled: a subspace owns its deployment, so a root entry the workspace lacks is left alone.
|
|
452
|
-
await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
|
|
453
|
-
await this.#extract(paths);
|
|
454
|
-
await this.#restoreRootEntries(heldRootEntries);
|
|
455
|
-
await this.#restoreSubspaceOwned(held);
|
|
456
|
-
|
|
457
|
-
for (const entry of this.#config.exclude)
|
|
458
|
-
if (!Subspace.subspaceOwnedRootEntries.includes(entry))
|
|
459
|
-
await rm(path.join(this.#clonePath, entry), { recursive: true, force: true });
|
|
460
|
-
|
|
461
|
-
await this.#rewriteGitignore();
|
|
462
|
-
const pruned = await this.#rewriteManifest(plans);
|
|
463
|
-
await this.#rewriteWorkspaceSection(libs);
|
|
464
|
-
await this.#writeLocalEnv();
|
|
465
|
-
for (const lib of libs) await this.#stampLib(clone, lib, branch);
|
|
466
|
-
return { libs, pruned };
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// `akan:secrets` names every workspace app, so it is filtered; `bun.lock` is un-ignored because a subspace commits it.
|
|
470
|
-
async #rewriteGitignore() {
|
|
471
|
-
const gitignorePath = path.join(this.#clonePath, ".gitignore");
|
|
472
|
-
if (!(await FileSys.fileExists(gitignorePath))) return;
|
|
473
|
-
const [begin = "", end = ""] = Subspace.secretsMarkers;
|
|
474
|
-
// `git archive` checks text out as CRLF under Windows Git's default `core.autocrlf=true`.
|
|
475
|
-
const lines = (await FileSys.readText(gitignorePath)).split(/\r?\n/);
|
|
476
|
-
const beginIdx = lines.indexOf(begin);
|
|
477
|
-
const endIdx = lines.indexOf(end);
|
|
478
|
-
const filtered =
|
|
479
|
-
beginIdx >= 0 && endIdx > beginIdx
|
|
480
|
-
? [
|
|
481
|
-
...lines.slice(0, beginIdx + 1),
|
|
482
|
-
...lines
|
|
483
|
-
.slice(beginIdx + 1, endIdx)
|
|
484
|
-
.filter((line) => this.#declaration.apps.some((app) => line.startsWith(`apps/${app}/`))),
|
|
485
|
-
...lines.slice(endIdx),
|
|
486
|
-
]
|
|
487
|
-
: lines;
|
|
488
|
-
await FileSys.writeText(gitignorePath, filtered.filter((line) => line.trim() !== "**/bun.lock").join("\n"));
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/** The generated `## Workspace` block names every app and library in the workspace. */
|
|
492
|
-
async #rewriteWorkspaceSection(libs: string[]) {
|
|
493
|
-
const replacements = [
|
|
494
|
-
[/^- Repo: .*$/m, `- Repo: ${this.name}`],
|
|
495
|
-
[/^- Apps: .*$/m, `- Apps: ${this.#declaration.apps.join(", ")}`],
|
|
496
|
-
[/^- Libraries: .*$/m, `- Libraries: ${libs.length ? libs.join(", ") : "(none)"}`],
|
|
497
|
-
] as const;
|
|
498
|
-
for (const file of ["AGENTS.md", "CLAUDE.md"]) {
|
|
499
|
-
const filePath = path.join(this.#clonePath, file);
|
|
500
|
-
if (!(await FileSys.fileExists(filePath))) continue;
|
|
501
|
-
const content = await FileSys.readText(filePath);
|
|
502
|
-
const rewritten = replacements.reduce((text, [pattern, value]) => text.replace(pattern, value), content);
|
|
503
|
-
if (rewritten !== content) await FileSys.writeText(filePath, rewritten);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// Not through `LibSource`, which addresses a library by its position in the workspace. The hash skips `env/` and
|
|
508
|
-
// the stamp itself.
|
|
509
|
-
async #stampLib(clone: Executor, lib: string, branch: string) {
|
|
510
|
-
const manifestPath = path.join(this.#clonePath, "libs", lib, "package.json");
|
|
511
|
-
if (!(await FileSys.fileExists(manifestPath))) return;
|
|
512
|
-
const origin = `${this.#workspace.repoName}#${branch}`;
|
|
513
|
-
const sha = await this.headSha();
|
|
514
|
-
const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
|
|
515
|
-
const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
|
|
516
|
-
const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
|
|
517
|
-
//* The extraction overwrote the manifest, so the previous stamp comes from the clone's HEAD. It is kept whole while
|
|
518
|
-
//* the library's content is unchanged — matching the workspace sha too would rewrite every manifest, and push,
|
|
519
|
-
//* after any unrelated workspace commit.
|
|
520
|
-
const unchanged = previous?.origin === origin && previous.hash === hash;
|
|
521
|
-
const source = unchanged ? previous : { origin, sha, hash, syncedAt: new Date().toISOString() };
|
|
522
|
-
manifest[LibSource.manifestKey] = { ...akan, source };
|
|
523
|
-
await FileSys.writeJson(manifestPath, manifest);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async #committedStamp(clone: Executor, lib: string) {
|
|
527
|
-
try {
|
|
528
|
-
const committed = await clone.spawn("git", ["show", `HEAD:libs/${lib}/package.json`]);
|
|
529
|
-
const manifest = JSON.parse(committed) as Record<string, unknown>;
|
|
530
|
-
const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
|
|
531
|
-
return akan.source as { origin: string; sha: string; hash: string; syncedAt: string } | undefined;
|
|
532
|
-
} catch {
|
|
533
|
-
//? First push: the library is not in the subspace's history yet.
|
|
534
|
-
return undefined;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
async #hashLib(clone: Executor, lib: string) {
|
|
539
|
-
const listed = await clone.spawn("git", [
|
|
540
|
-
"ls-files",
|
|
541
|
-
"-z",
|
|
542
|
-
"--cached",
|
|
543
|
-
"--others",
|
|
544
|
-
"--exclude-standard",
|
|
545
|
-
"--",
|
|
546
|
-
`libs/${lib}`,
|
|
547
|
-
]);
|
|
548
|
-
const candidates = listed
|
|
549
|
-
.split("\0")
|
|
550
|
-
.filter((file) => !!file && !this.#isSubspaceOwned(file))
|
|
551
|
-
.sort();
|
|
552
|
-
//* The index still lists files the workspace deleted since the last push (only the push's `git add -A`
|
|
553
|
-
//* reconciles it), and opening one throws.
|
|
554
|
-
const present = await Promise.all(
|
|
555
|
-
candidates.map(async (file) => await FileSys.entryExists(path.join(this.#clonePath, file))),
|
|
556
|
-
);
|
|
557
|
-
const files = candidates.filter((_, index) => present[index]);
|
|
558
|
-
return await LibSource.hashFiles(files, `libs/${lib}/package.json`, (file) =>
|
|
559
|
-
FileSys.readText(path.join(this.#clonePath, file)),
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
288
|
/** Direction is subspace → workspace, so the patch reads as what a push would apply rather than its inverse. */
|
|
564
289
|
async diff(branch: string, filter?: string | null): Promise<SubspaceDiffResult> {
|
|
565
290
|
if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
|
|
@@ -584,40 +309,33 @@ export class Subspace {
|
|
|
584
309
|
}
|
|
585
310
|
|
|
586
311
|
// A lock written against a local registry (verdaccio) resolves on this machine alone: every install of it fails.
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
if (!(await FileSys.fileExists(lockPath))) return null;
|
|
590
|
-
const registry = (await FileSys.readText(lockPath)).match(Subspace.#loopbackRegistry)?.[0];
|
|
312
|
+
static #loopbackRegistryProblem(lock: string | null) {
|
|
313
|
+
const registry = lock?.match(Subspace.#loopbackRegistry)?.[0];
|
|
591
314
|
return registry
|
|
592
315
|
? `bun.lock resolves packages from ${registry}, a registry only this machine reaches; reinstall from a public registry and commit that lock first`
|
|
593
316
|
: null;
|
|
594
317
|
}
|
|
595
318
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
async #verify(clone: Executor) {
|
|
599
|
-
const env = { ...process.env, ...this.#localEnv() };
|
|
600
|
-
try {
|
|
601
|
-
await clone.spawn("bun", ["install"], { env });
|
|
602
|
-
for (const app of this.#declaration.apps) await clone.spawn("bunx", ["akan", "sync", app], { env });
|
|
603
|
-
return null;
|
|
604
|
-
} catch (error) {
|
|
605
|
-
return error instanceof Error ? error.message : String(error);
|
|
606
|
-
}
|
|
319
|
+
async #headLockProblem() {
|
|
320
|
+
return Subspace.#loopbackRegistryProblem(await this.#git(["show", "HEAD:bun.lock"]).catch(() => null));
|
|
607
321
|
}
|
|
608
322
|
|
|
609
323
|
async push(branch: string, { verify = true }: { verify?: boolean } = {}): Promise<SubspacePushResult> {
|
|
610
324
|
this.#config.assertPushable(branch);
|
|
611
|
-
|
|
612
|
-
|
|
325
|
+
const refusal = (await this.#workspaceRefusal()) ?? (await this.#headLockProblem());
|
|
326
|
+
if (refusal) return { name: this.name, outcome: "refused", reason: refusal, changedFiles: 0 };
|
|
613
327
|
if (!(await this.fetch(branch)))
|
|
614
328
|
return { name: this.name, outcome: "refused", reason: `subspace has no branch "${branch}"`, changedFiles: 0 };
|
|
615
329
|
|
|
616
|
-
const clone = await this.#
|
|
617
|
-
const { pruned } = await
|
|
330
|
+
const clone = await SubspaceClone.open(this.#workspace, this.#declaration, branch);
|
|
331
|
+
const { pruned } = await clone.applySlice(await this.slice(), {
|
|
332
|
+
branch,
|
|
333
|
+
sha: await this.headSha(),
|
|
334
|
+
remove: [Subspace.retiredConfigFile, ...this.#config.exclude],
|
|
335
|
+
});
|
|
618
336
|
|
|
619
|
-
const
|
|
620
|
-
if (!
|
|
337
|
+
const changedFiles = await clone.changedFiles();
|
|
338
|
+
if (!changedFiles)
|
|
621
339
|
return {
|
|
622
340
|
name: this.name,
|
|
623
341
|
outcome: "skipped",
|
|
@@ -626,52 +344,37 @@ export class Subspace {
|
|
|
626
344
|
prunedDependencies: pruned,
|
|
627
345
|
};
|
|
628
346
|
|
|
629
|
-
const changedFiles = dirty.split("\n").length;
|
|
630
347
|
if (verify) {
|
|
631
|
-
const failure = await
|
|
348
|
+
const failure = await clone.verify();
|
|
632
349
|
if (failure) return { name: this.name, outcome: "refused", reason: failure, changedFiles };
|
|
633
350
|
}
|
|
634
|
-
|
|
351
|
+
//? Rechecked on the clone: the verifying `bun install` may resolve packages from this machine's registry into it.
|
|
352
|
+
const lockProblem = Subspace.#loopbackRegistryProblem(await clone.lock());
|
|
635
353
|
if (lockProblem) return { name: this.name, outcome: "refused", reason: lockProblem, changedFiles };
|
|
636
|
-
await this.#writeAnchorFile(branch);
|
|
637
|
-
await clone.spawn("git", ["add", "-A"]);
|
|
638
|
-
const message = `${Subspace.syncSubjectPrefix}${this.#workspace.repoName}@${await this.headSha()}`;
|
|
639
|
-
await clone.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
640
|
-
//* Never force: a diverged subspace holds customer commits, and `pull` is how those come back.
|
|
641
|
-
await clone.spawn("git", ["push", "--quiet", "origin", branch]);
|
|
642
354
|
return {
|
|
643
355
|
name: this.name,
|
|
644
356
|
outcome: "pushed",
|
|
645
|
-
commit:
|
|
357
|
+
commit: await clone.commit(branch, await this.headSha()),
|
|
646
358
|
changedFiles,
|
|
647
359
|
prunedDependencies: pruned,
|
|
648
360
|
};
|
|
649
361
|
}
|
|
650
362
|
|
|
651
|
-
async #writeAnchorFile(branch: string) {
|
|
652
|
-
await FileSys.writeJson(path.join(this.#clonePath, Subspace.anchorFile), {
|
|
653
|
-
workspace: this.#workspace.repoName,
|
|
654
|
-
hubSha: await this.headSha(),
|
|
655
|
-
branch,
|
|
656
|
-
apps: this.#declaration.apps,
|
|
657
|
-
syncedAt: new Date().toISOString(),
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
|
|
661
363
|
async pull(branch: string, { adoptLibs = false }: { adoptLibs?: boolean } = {}): Promise<SubspacePullResult> {
|
|
364
|
+
if (await this.#retiredConfigLeft()) throw new Error(Subspace.#retiredConfigRefusal);
|
|
662
365
|
if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
|
|
663
366
|
const anchor = await this.anchor(branch);
|
|
664
367
|
if (!anchor.commit)
|
|
665
|
-
throw new Error(`Subspace "${this.name}" carries no ${
|
|
368
|
+
throw new Error(`Subspace "${this.name}" carries no ${SubspaceClone.anchorFile} — it has never received a push`);
|
|
666
369
|
if (!anchor.incoming.length) return { name: this.name, applied: [], libPatch: null, ignored: [], incoming: [] };
|
|
667
370
|
|
|
668
371
|
const range = `${anchor.commit}..${this.#remote}/${branch}`;
|
|
669
372
|
const changed = (await this.#git(["diff", "--name-only", range])).split("\n").filter((file) => !!file.trim());
|
|
670
373
|
const appPrefixes = this.#declaration.apps.map((app) => `apps/${app}/`);
|
|
671
374
|
const appFiles = changed.filter(
|
|
672
|
-
(file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !
|
|
375
|
+
(file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !SubspaceClone.isSubspaceOwned(file),
|
|
673
376
|
);
|
|
674
|
-
const libFiles = changed.filter((file) => file.startsWith("libs/") && !
|
|
377
|
+
const libFiles = changed.filter((file) => file.startsWith("libs/") && !SubspaceClone.isSubspaceOwned(file));
|
|
675
378
|
const ignored = changed.filter((file) => !appFiles.includes(file) && !libFiles.includes(file));
|
|
676
379
|
|
|
677
380
|
const applied = adoptLibs ? [...appFiles, ...libFiles] : appFiles;
|
|
@@ -695,154 +398,3 @@ export class Subspace {
|
|
|
695
398
|
return { path: path.relative(this.#workspace.workspaceRoot, patchPath), absolute: patchPath, files };
|
|
696
399
|
}
|
|
697
400
|
}
|
|
698
|
-
|
|
699
|
-
export function formatSubspaceStatuses(statuses: SubspaceStatus[]) {
|
|
700
|
-
const sections = [
|
|
701
|
-
"Akan Subspace Status",
|
|
702
|
-
`branch: ${statuses[0]?.branch ?? "(none)"}`,
|
|
703
|
-
"",
|
|
704
|
-
...statuses.flatMap((status) => {
|
|
705
|
-
if (!status.hasBranch) return [` ${status.name}: no such branch in the subspace — push is refused`];
|
|
706
|
-
const behind = status.behindPaths.length ? `${status.behindPaths.length} file(s) behind` : "up to date";
|
|
707
|
-
const incoming = status.anchor.incoming.length
|
|
708
|
-
? `${status.anchor.incoming.length} customer commit(s) to pull`
|
|
709
|
-
: "no customer commits";
|
|
710
|
-
const drift = status.driftedLibs.length ? ` DRIFTED LIBS: ${status.driftedLibs.join(", ")}` : null;
|
|
711
|
-
return [
|
|
712
|
-
` ${status.name}: ${behind}, ${incoming}`,
|
|
713
|
-
...status.anchor.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`),
|
|
714
|
-
...(drift ? [drift] : []),
|
|
715
|
-
];
|
|
716
|
-
}),
|
|
717
|
-
];
|
|
718
|
-
return sections.join("\n");
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
export function formatSubspacePushResults(results: SubspacePushResult[]) {
|
|
722
|
-
const sections = [
|
|
723
|
-
"Akan Subspace Push",
|
|
724
|
-
"",
|
|
725
|
-
...results.flatMap((result) => {
|
|
726
|
-
const detail =
|
|
727
|
-
result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : (result.reason ?? "");
|
|
728
|
-
const [first = "", ...rest] = detail.split("\n");
|
|
729
|
-
const pruned = result.prunedDependencies?.length
|
|
730
|
-
? [` ${result.prunedDependencies.length} unused root dependenc(ies) left out of package.json`]
|
|
731
|
-
: [];
|
|
732
|
-
return [
|
|
733
|
-
` ${result.outcome.padEnd(8)} ${result.name} ${first}`,
|
|
734
|
-
...rest.map((line) => ` ${line}`),
|
|
735
|
-
...pruned,
|
|
736
|
-
];
|
|
737
|
-
}),
|
|
738
|
-
];
|
|
739
|
-
return sections.join("\n");
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
export function formatSubspacePullResult(result: SubspacePullResult) {
|
|
743
|
-
const sections = [
|
|
744
|
-
`Akan Subspace Pull — ${result.name}`,
|
|
745
|
-
"",
|
|
746
|
-
`Customer commits since the last push (${result.incoming.length}):`,
|
|
747
|
-
"",
|
|
748
|
-
...(result.incoming.length
|
|
749
|
-
? result.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`)
|
|
750
|
-
: [" (none — nothing to pull)"]),
|
|
751
|
-
"",
|
|
752
|
-
`Applied to the working tree, uncommitted (${result.applied.length}):`,
|
|
753
|
-
"",
|
|
754
|
-
...(result.applied.length ? result.applied.map((file) => ` ${file}`) : [" (none)"]),
|
|
755
|
-
...(result.libPatch
|
|
756
|
-
? [
|
|
757
|
-
"",
|
|
758
|
-
`Library edits NOT applied (${result.libPatch.files.length}) — review before adopting:`,
|
|
759
|
-
"",
|
|
760
|
-
...result.libPatch.files.map((file) => ` ${file}`),
|
|
761
|
-
"",
|
|
762
|
-
` patch: ${result.libPatch.path}`,
|
|
763
|
-
" adopt with: akan subspace pull <name> --adopt-libs",
|
|
764
|
-
]
|
|
765
|
-
: []),
|
|
766
|
-
...(result.ignored.length
|
|
767
|
-
? ["", `Ignored (workspace-owned or subspace-owned): ${result.ignored.length} file(s)`]
|
|
768
|
-
: []),
|
|
769
|
-
];
|
|
770
|
-
return sections.join("\n");
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
export function formatSubspaceEnvUpload(result: SubspaceEnvUploadResult) {
|
|
774
|
-
const sections = [
|
|
775
|
-
`Akan Subspace Env Upload — ${result.name}`,
|
|
776
|
-
`cloud workspace: ${result.workspaceId} · host: ${result.host}`,
|
|
777
|
-
"",
|
|
778
|
-
...(result.outcome === "cancelled"
|
|
779
|
-
? [" cancelled — the subspace's env archive is untouched."]
|
|
780
|
-
: [
|
|
781
|
-
`Uploaded (${result.files.length}), replacing that workspace's whole archive:`,
|
|
782
|
-
"",
|
|
783
|
-
...result.files.map((file) => ` ${file}`),
|
|
784
|
-
"",
|
|
785
|
-
` apps: ${result.apps.join(", ")}`,
|
|
786
|
-
` libs: ${result.libs.join(", ") || "(none)"}`,
|
|
787
|
-
]),
|
|
788
|
-
];
|
|
789
|
-
return sections.join("\n");
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
export function formatSubspaceDiff(result: SubspaceDiffResult) {
|
|
793
|
-
const total = result.app.files.length + result.libs.files.length;
|
|
794
|
-
const sections = [
|
|
795
|
-
`Akan Subspace Diff — ${result.name} (${result.branch})`,
|
|
796
|
-
"what `akan subspace push` would change in the subspace",
|
|
797
|
-
"",
|
|
798
|
-
...(total ? [] : [" subspace is identical to the workspace for this slice."]),
|
|
799
|
-
...(result.libs.files.length
|
|
800
|
-
? [
|
|
801
|
-
`LIBRARY changes (${result.libs.files.length}) — the subspace edited shared code:`,
|
|
802
|
-
"",
|
|
803
|
-
...result.libs.files.map((file) => ` ${file}`),
|
|
804
|
-
"",
|
|
805
|
-
result.libs.patch,
|
|
806
|
-
]
|
|
807
|
-
: []),
|
|
808
|
-
...(result.app.files.length
|
|
809
|
-
? [
|
|
810
|
-
`App changes (${result.app.files.length}):`,
|
|
811
|
-
"",
|
|
812
|
-
...result.app.files.map((file) => ` ${file}`),
|
|
813
|
-
"",
|
|
814
|
-
result.app.patch,
|
|
815
|
-
]
|
|
816
|
-
: []),
|
|
817
|
-
];
|
|
818
|
-
return sections.join("\n");
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
export function formatSubspacePlan(plan: SubspacePlan) {
|
|
822
|
-
const list = (entries: string[]) => (entries.length ? entries.join(", ") : "(none)");
|
|
823
|
-
const blockers = [plan.branchRefusal, plan.lockProblem].filter((reason): reason is string => !!reason);
|
|
824
|
-
const sections = [
|
|
825
|
-
`Akan Subspace Plan — ${plan.name} (${plan.branch})`,
|
|
826
|
-
`repo: ${plan.repo}`,
|
|
827
|
-
"what `akan subspace push` would ship, worked out from this checkout alone",
|
|
828
|
-
"",
|
|
829
|
-
`apps: ${list(plan.apps)}`,
|
|
830
|
-
`libs: ${plan.libs.length ? "" : "(none)"}`.trimEnd(),
|
|
831
|
-
...plan.libs.map((lib) => ` - ${lib.name} (from ${lib.apps.join(", ")})`),
|
|
832
|
-
`files under apps/ and libs/: ${plan.files}`,
|
|
833
|
-
"",
|
|
834
|
-
`root entries pushed: ${list(plan.rootEntries)}`,
|
|
835
|
-
`root entries excluded: ${list(plan.excludedRootEntries)}`,
|
|
836
|
-
`root entries the subspace keeps its own: ${list(plan.subspaceOwnedRootEntries)}`,
|
|
837
|
-
"",
|
|
838
|
-
`Root dependencies left out of package.json (${plan.prunedDependencies.length}):`,
|
|
839
|
-
...(plan.prunedDependencies.length ? plan.prunedDependencies.map((dep) => ` - ${dep}`) : [" (none)"]),
|
|
840
|
-
"",
|
|
841
|
-
blockers.length ? "A push would be refused:" : "Nothing here would make a push refuse.",
|
|
842
|
-
...blockers.map((reason) => ` - ${reason}`),
|
|
843
|
-
...(plan.warnings.length
|
|
844
|
-
? ["", `Warnings (${plan.warnings.length}):`, ...plan.warnings.map((w) => ` - ${w}`)]
|
|
845
|
-
: []),
|
|
846
|
-
];
|
|
847
|
-
return sections.join("\n");
|
|
848
|
-
}
|