@akanjs/devkit 3.1.0-rc.26 → 3.1.0-rc.27
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/applicationBuildRunner.test.ts +36 -0
- package/applicationBuildRunner.ts +5 -3
- package/biome.base.json +1 -0
- package/biomeBase.test.ts +78 -0
- package/codeAgent/skills/akan-store/SKILL.md +2 -1
- package/codeAgent/tools/WebToolPack.ts +42 -17
- package/codeAgent/tools/akanCodePlugins.test.ts +71 -3
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +39 -0
- package/executors.test.ts +29 -0
- package/executors.ts +9 -5
- package/lint/__fixtures__/no-conditional-st-hook/bad.tsx +21 -0
- package/lint/__fixtures__/no-conditional-st-hook/good.tsx +11 -0
- package/lint/no-conditional-st-hook.grit +94 -23
- package/package.json +2 -2
- package/subspace.test.ts +3 -2
- package/subspaceClone.ts +1 -12
|
@@ -172,4 +172,40 @@ console.info(new SampleService().name);
|
|
|
172
172
|
await rm(root, { recursive: true, force: true });
|
|
173
173
|
}
|
|
174
174
|
});
|
|
175
|
+
|
|
176
|
+
test("asks the bundler for a metafile with AKAN_BUILD_METAFILE=1, and not with 0, false or an empty value", async () => {
|
|
177
|
+
const { root, workspace } = await createTempApp("portal");
|
|
178
|
+
const keys = [
|
|
179
|
+
"AKAN_BUILD_METAFILE",
|
|
180
|
+
"AKAN_PUBLIC_ENV",
|
|
181
|
+
"AKAN_PUBLIC_REPO_NAME",
|
|
182
|
+
"AKAN_PUBLIC_SERVE_DOMAIN",
|
|
183
|
+
] as const;
|
|
184
|
+
const shellEnv = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
185
|
+
await Promise.all(["main.ts", "server.ts"].map((file) => writeFile(path.join(root, "apps", "portal", file), "")));
|
|
186
|
+
const app = AppExecutor.from(workspace, "portal");
|
|
187
|
+
app.prepareCommand = async () => ({ env: {} });
|
|
188
|
+
const bundle = spyOn(Bun, "build").mockImplementation(async () => {
|
|
189
|
+
throw new Error("stopped at the first bundle");
|
|
190
|
+
});
|
|
191
|
+
const asksForMetafile = async (flag: string) => {
|
|
192
|
+
process.env.AKAN_BUILD_METAFILE = flag;
|
|
193
|
+
await expect(new ApplicationBuildRunner(app, { fast: true }).build()).rejects.toThrow(
|
|
194
|
+
"stopped at the first bundle",
|
|
195
|
+
);
|
|
196
|
+
return bundle.mock.lastCall?.[0].metafile === true;
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
const asked: { [flag: string]: boolean } = {};
|
|
200
|
+
for (const flag of ["1", "0", "false", ""]) asked[flag] = await asksForMetafile(flag);
|
|
201
|
+
expect(asked).toEqual({ "1": true, "0": false, false: false, "": false });
|
|
202
|
+
} finally {
|
|
203
|
+
bundle.mockRestore();
|
|
204
|
+
for (const key of keys) {
|
|
205
|
+
if (shellEnv[key] === undefined) delete process.env[key];
|
|
206
|
+
else process.env[key] = shellEnv[key];
|
|
207
|
+
}
|
|
208
|
+
await rm(root, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
175
211
|
});
|
|
@@ -387,9 +387,11 @@ void run().catch((error) => {
|
|
|
387
387
|
|
|
388
388
|
//? Opt-in and outside `dist/`: a metafile is the size of the module graph, and `dist/` is what the image copies.
|
|
389
389
|
async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
390
|
+
const flag = process.env.AKAN_BUILD_METAFILE;
|
|
391
|
+
const metafileDir =
|
|
392
|
+
flag && flag !== "0" && flag !== "false"
|
|
393
|
+
? path.join(this.#app.workspace.workspaceRoot, "local", "build-meta", path.basename(this.#app.cwdPath))
|
|
394
|
+
: null;
|
|
393
395
|
const result = await Bun.build(metafileDir ? { ...config, metafile: true } : config);
|
|
394
396
|
if (!result.success) throw new AggregateError(result.logs, `[${label}] Bun.build failed`);
|
|
395
397
|
if (metafileDir && result.metafile) {
|
package/biome.base.json
CHANGED
|
@@ -196,6 +196,7 @@
|
|
|
196
196
|
},
|
|
197
197
|
{
|
|
198
198
|
"includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
|
|
199
|
+
"linter": { "rules": { "correctness": { "useHookAtTopLevel": "error" } } },
|
|
199
200
|
"plugins": [
|
|
200
201
|
"./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit",
|
|
201
202
|
"./node_modules/@akanjs/devkit/lint/no-conditional-st-hook.grit"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { biomeBaseConfig } from "./biomeBase";
|
|
7
|
+
import { Linter, parseBiomeReport } from "./linter";
|
|
8
|
+
|
|
9
|
+
const devkitDir = import.meta.dir;
|
|
10
|
+
const biomeBin = path.resolve(devkitDir, "../../../node_modules/.bin", Linter.biomeBinName);
|
|
11
|
+
|
|
12
|
+
const hookProbe = `export const Early = ({ id }: { id?: string }) => {
|
|
13
|
+
if (!id) return null;
|
|
14
|
+
const [count] = useState(0);
|
|
15
|
+
return <p>{count}</p>;
|
|
16
|
+
};
|
|
17
|
+
export const Branch = ({ open }: { open: boolean }) => {
|
|
18
|
+
if (open) useEffect(() => {}, []);
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const tempDirs: string[] = [];
|
|
24
|
+
|
|
25
|
+
afterAll(async () => {
|
|
26
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const runBiome = (args: string[], cwd: string) =>
|
|
30
|
+
new Promise<string>((resolve, reject) => {
|
|
31
|
+
const proc = spawn(biomeBin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
32
|
+
let stdout = "";
|
|
33
|
+
let stderr = "";
|
|
34
|
+
proc.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
|
|
35
|
+
proc.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
|
|
36
|
+
proc.on("error", reject);
|
|
37
|
+
proc.on("close", () => resolve(stdout || stderr));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const lintWorkspace = async (files: string[]) => {
|
|
41
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "biome-base-"));
|
|
42
|
+
tempDirs.push(dir);
|
|
43
|
+
//? The base config names its plugins as `./node_modules/@akanjs/devkit/…`, resolved from the extending config's
|
|
44
|
+
//? folder. A junction needs no privilege on Windows, and the type is ignored elsewhere.
|
|
45
|
+
await mkdir(path.join(dir, "node_modules", "@akanjs"), { recursive: true });
|
|
46
|
+
await symlink(devkitDir, path.join(dir, "node_modules", "@akanjs", "devkit"), "junction");
|
|
47
|
+
await writeFile(path.join(dir, "biome.json"), JSON.stringify({ extends: [biomeBaseConfig] }));
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
await mkdir(path.dirname(path.join(dir, file)), { recursive: true });
|
|
50
|
+
await writeFile(path.join(dir, file), hookProbe);
|
|
51
|
+
}
|
|
52
|
+
const output = await runBiome(["lint", "--reporter=json", "--max-diagnostics=none", ...files], dir);
|
|
53
|
+
return parseBiomeReport(output).diagnostics ?? [];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("biome.base.json", () => {
|
|
57
|
+
test("reports a conditional React hook in app and lib components, not in their tests or in packages", async () => {
|
|
58
|
+
const diagnostics = await lintWorkspace([
|
|
59
|
+
"apps/demo/ui/Probe.tsx",
|
|
60
|
+
"libs/demo/ui/Probe.tsx",
|
|
61
|
+
"apps/demo/ui/Probe.test.tsx",
|
|
62
|
+
"pkgs/demo/Probe.tsx",
|
|
63
|
+
]);
|
|
64
|
+
const hookReports = diagnostics
|
|
65
|
+
.filter((diagnostic) => diagnostic.category === "lint/correctness/useHookAtTopLevel")
|
|
66
|
+
.map(
|
|
67
|
+
(diagnostic) =>
|
|
68
|
+
`${diagnostic.location?.path?.replaceAll("\\", "/")}:${diagnostic.location?.start?.line} ${diagnostic.severity}`,
|
|
69
|
+
)
|
|
70
|
+
.sort();
|
|
71
|
+
expect(hookReports).toEqual([
|
|
72
|
+
"apps/demo/ui/Probe.tsx:3 error",
|
|
73
|
+
"apps/demo/ui/Probe.tsx:7 error",
|
|
74
|
+
"libs/demo/ui/Probe.tsx:3 error",
|
|
75
|
+
"libs/demo/ui/Probe.tsx:7 error",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -19,7 +19,8 @@ For a model `comment` (`<Slice>` is a signal slice suffix, e.g. `InPublic`):
|
|
|
19
19
|
`add<Field>OnComment(value)` / `sub<Field>OnComment(idx)` for arrays, `upload<Field>OnComment(fileList)`
|
|
20
20
|
for file fields, `checkCommentSubmitable()`, `submitComment()`, `createCommentInForm()`,
|
|
21
21
|
`updateCommentInForm()`
|
|
22
|
-
- single: `viewComment(model)`, `mergeComment(model, data)`, `setComment(...models)`, `resetComment()
|
|
22
|
+
- single: `viewComment(model)`, `mergeComment(model, data)`, `setComment(...models)`, `resetComment()`,
|
|
23
|
+
`invalidateComment()` (marks every slice stale after a custom mutation, so the next mount refetches)
|
|
23
24
|
- per slice: `initComment<Slice>(...args)`, `refreshComment<Slice>()`, `selectComment<Slice>(model)`,
|
|
24
25
|
`setPageOfComment<Slice>(page)`, `loadMoreOfComment<Slice>()`, `setLimitOfComment<Slice>(limit)`,
|
|
25
26
|
`setQueryArgsOfComment<Slice>(...)`, `setSortOfComment<Slice>(sort)`
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
1
2
|
import { isIP } from "node:net";
|
|
2
3
|
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { type CodeAgentProfile, codeAgentClip } from "akanjs/common";
|
|
@@ -8,6 +9,7 @@ export interface WebToolPackOptions {
|
|
|
8
9
|
profile: CodeAgentProfile;
|
|
9
10
|
/** Serves search after any key-named provider, and reads public pages to Markdown ahead of a direct fetch. */
|
|
10
11
|
deepseek?: DeepSeekWeb | undefined;
|
|
12
|
+
lookup?: ((host: string) => Promise<string[]>) | undefined;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
interface SearchProvider {
|
|
@@ -163,7 +165,7 @@ export class WebToolPack {
|
|
|
163
165
|
|
|
164
166
|
async #read(url: string, signal?: AbortSignal): Promise<WebPage> {
|
|
165
167
|
const deepseek = this.#options.deepseek;
|
|
166
|
-
if (deepseek && !
|
|
168
|
+
if (deepseek && !(await this.#isLocal(new URL(url).hostname))) {
|
|
167
169
|
try {
|
|
168
170
|
const page = await deepseek.fetch(url, signal);
|
|
169
171
|
return { status: 200, ok: true, title: page.title, text: page.content };
|
|
@@ -196,23 +198,46 @@ export class WebToolPack {
|
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
// DeepSeek's servers cannot reach these, and an intranet name has no business leaving the machine.
|
|
199
|
-
|
|
200
|
-
const host = hostname.replace(/^\[|\]
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
return (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
(a === 100 && b >= 64 && b <= 127) ||
|
|
210
|
-
(a === 169 && b === 254) ||
|
|
211
|
-
(a === 172 && b >= 16 && b <= 31) ||
|
|
212
|
-
(a === 192 && b === 168)
|
|
213
|
-
);
|
|
201
|
+
async #isLocal(hostname: string) {
|
|
202
|
+
const host = hostname.replace(/^\[|\]$|\.$/g, "").toLowerCase();
|
|
203
|
+
if (isIP(host)) return WebToolPack.#isLocalAddress(host);
|
|
204
|
+
if (!host.includes(".") || /\.(localhost|local|internal|lan|home\.arpa)$/.test(host)) return true;
|
|
205
|
+
try {
|
|
206
|
+
const addresses = await (this.#options.lookup ?? WebToolPack.#lookup)(host);
|
|
207
|
+
return !addresses.length || addresses.some((address) => WebToolPack.#isLocalAddress(address));
|
|
208
|
+
} catch {
|
|
209
|
+
// Fails closed: an intranet name this machine cannot resolve right now is still an intranet name.
|
|
210
|
+
return true;
|
|
214
211
|
}
|
|
215
|
-
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
static async #lookup(host: string) {
|
|
215
|
+
return (await lookup(host, { all: true })).map((entry) => entry.address);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
static #isLocalAddress(literal: string) {
|
|
219
|
+
const address = WebToolPack.#unmapped(literal);
|
|
220
|
+
if (isIP(address) === 6) return address === "::" || address === "::1" || /^(f[cd]|fe[89ab])/.test(address);
|
|
221
|
+
const [a = 0, b = 0] = address.split(".").map(Number);
|
|
222
|
+
return (
|
|
223
|
+
a === 0 ||
|
|
224
|
+
a === 10 ||
|
|
225
|
+
a === 127 ||
|
|
226
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
227
|
+
(a === 169 && b === 254) ||
|
|
228
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
229
|
+
(a === 192 && b === 168)
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The URL parser writes `[::ffff:127.0.0.1]` as `::ffff:7f00:1`; a resolver hands a mapped address back dotted.
|
|
234
|
+
static #unmapped(address: string) {
|
|
235
|
+
const mapped = /^::ffff:(?:(\d+\.\d+\.\d+\.\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/i.exec(address);
|
|
236
|
+
if (!mapped) return address;
|
|
237
|
+
if (mapped[1]) return mapped[1];
|
|
238
|
+
const high = Number.parseInt(mapped[2] ?? "0", 16);
|
|
239
|
+
const low = Number.parseInt(mapped[3] ?? "0", 16);
|
|
240
|
+
return [high >> 8, high & 255, low >> 8, low & 255].join(".");
|
|
216
241
|
}
|
|
217
242
|
|
|
218
243
|
static #refuseHost(url: string, allowHosts: string[] | undefined) {
|
|
@@ -709,9 +709,9 @@ describe("WebToolPack", () => {
|
|
|
709
709
|
}) as typeof fetch);
|
|
710
710
|
try {
|
|
711
711
|
const deepseek = new DeepSeekWeb("sk-test", `http://127.0.0.1:${deepseekServer.port}/v1/tools`);
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
);
|
|
712
|
+
const lookup = async () => ["93.184.215.14"];
|
|
713
|
+
const pack = new WebToolPack({ profile: codeAgentPresets.local(root), deepseek, lookup });
|
|
714
|
+
const fetchTool = toolsOf(pack.extension()).get("web_fetch");
|
|
715
715
|
const read = async (url: string) => (await fetchTool?.("1", { url } as never))?.content[0]?.text ?? "";
|
|
716
716
|
|
|
717
717
|
expect(await read("https://docs.example.com/install")).toBe(
|
|
@@ -728,6 +728,74 @@ describe("WebToolPack", () => {
|
|
|
728
728
|
}
|
|
729
729
|
});
|
|
730
730
|
|
|
731
|
+
test("reads an intranet name, a mapped or unspecified loopback, and a name that does not resolve directly", async () => {
|
|
732
|
+
const asked: string[] = [];
|
|
733
|
+
const deepseekServer = Bun.serve({
|
|
734
|
+
port: 0,
|
|
735
|
+
fetch: async (request) => {
|
|
736
|
+
asked.push(((await request.json()) as { url: string }).url);
|
|
737
|
+
return Response.json({ title: "Public", content: "read by DeepSeek" });
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
const realFetch = globalThis.fetch;
|
|
741
|
+
const direct = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
742
|
+
input: RequestInfo | URL,
|
|
743
|
+
init?: RequestInit,
|
|
744
|
+
) => {
|
|
745
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
746
|
+
if (new URL(url).port === String(deepseekServer.port)) return await realFetch(input, init);
|
|
747
|
+
return new Response("<p>read directly</p>");
|
|
748
|
+
}) as typeof fetch);
|
|
749
|
+
const addresses: { [host: string]: string[] } = {
|
|
750
|
+
"jira.corp.example.com": ["10.0.0.7"],
|
|
751
|
+
"wiki.example.com": ["93.184.215.14", "fd12::1"],
|
|
752
|
+
"hosts.example.com": ["::ffff:192.168.0.2"],
|
|
753
|
+
"docs.example.com": ["93.184.215.14", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"],
|
|
754
|
+
};
|
|
755
|
+
const looked: string[] = [];
|
|
756
|
+
const lookup = async (host: string) => {
|
|
757
|
+
looked.push(host);
|
|
758
|
+
const found = addresses[host];
|
|
759
|
+
if (!found) throw new Error(`getaddrinfo ENOTFOUND ${host}`);
|
|
760
|
+
return found;
|
|
761
|
+
};
|
|
762
|
+
try {
|
|
763
|
+
const deepseek = new DeepSeekWeb("sk-test", `http://127.0.0.1:${deepseekServer.port}/v1/tools`);
|
|
764
|
+
const pack = new WebToolPack({ profile: codeAgentPresets.local(root), deepseek, lookup });
|
|
765
|
+
const fetchTool = toolsOf(pack.extension()).get("web_fetch");
|
|
766
|
+
const read = async (url: string) => (await fetchTool?.("1", { url } as never))?.content[0]?.text ?? "";
|
|
767
|
+
|
|
768
|
+
for (const url of [
|
|
769
|
+
"http://jira.corp.example.com/browse/SEC-1",
|
|
770
|
+
"http://wiki.example.com/",
|
|
771
|
+
"http://hosts.example.com/",
|
|
772
|
+
"http://foo.test/",
|
|
773
|
+
"http://[::ffff:127.0.0.1]:8282/x",
|
|
774
|
+
"http://[::ffff:10.0.0.5]/wiki",
|
|
775
|
+
"http://[::]:8282/",
|
|
776
|
+
"http://0.0.0.0:8282/",
|
|
777
|
+
"http://localhost.:8282/",
|
|
778
|
+
"http://app.local./",
|
|
779
|
+
])
|
|
780
|
+
expect(await read(url)).toContain("read directly");
|
|
781
|
+
expect(asked).toEqual([]);
|
|
782
|
+
|
|
783
|
+
expect(await read("https://docs.example.com/install")).toContain("read by DeepSeek");
|
|
784
|
+
expect(await read("http://[2606:4700:4700::1111]/")).toContain("read by DeepSeek");
|
|
785
|
+
expect(asked).toEqual(["https://docs.example.com/install", "http://[2606:4700:4700::1111]/"]);
|
|
786
|
+
expect(looked).toEqual([
|
|
787
|
+
"jira.corp.example.com",
|
|
788
|
+
"wiki.example.com",
|
|
789
|
+
"hosts.example.com",
|
|
790
|
+
"foo.test",
|
|
791
|
+
"docs.example.com",
|
|
792
|
+
]);
|
|
793
|
+
} finally {
|
|
794
|
+
direct.mockRestore();
|
|
795
|
+
deepseekServer.stop(true);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
731
799
|
test("searches through DeepSeek when no key-named provider is set, and a key-named one wins", async () => {
|
|
732
800
|
const had = { brave: process.env.BRAVE_API_KEY, tavily: process.env.TAVILY_API_KEY };
|
|
733
801
|
const counts: number[] = [];
|
|
@@ -384,6 +384,8 @@ export const runCommands = async (...commands: CommandCls[]) => {
|
|
|
384
384
|
Akan CLI version is mismatch with installed package. ${process.env.AKAN_VERSION} (global) vs ${installedAkanPackageJson.version} (akanjs)
|
|
385
385
|
It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
386
386
|
),
|
|
387
|
+
undefined,
|
|
388
|
+
"error",
|
|
387
389
|
);
|
|
388
390
|
}
|
|
389
391
|
|
|
@@ -659,3 +659,42 @@ describe("a repeatable option", () => {
|
|
|
659
659
|
expect(await collect()).toEqual([]);
|
|
660
660
|
}, 30_000);
|
|
661
661
|
});
|
|
662
|
+
|
|
663
|
+
describe("a workspace whose akanjs is not the CLI's version", () => {
|
|
664
|
+
const track = tempRoots();
|
|
665
|
+
|
|
666
|
+
test("hears so on stderr, leaving stdout to what the command prints", async () => {
|
|
667
|
+
const { root } = track(await makeCliTempWorkspace());
|
|
668
|
+
await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
|
|
669
|
+
await writeText(path.join(root, "tsconfig.json"), "{}\n");
|
|
670
|
+
await writeText(
|
|
671
|
+
path.join(root, "node_modules", "akanjs", "package.json"),
|
|
672
|
+
'{ "name": "akanjs", "version": "0.0.1-older" }\n',
|
|
673
|
+
);
|
|
674
|
+
const entry = path.join(root, "cli.ts");
|
|
675
|
+
await writeText(
|
|
676
|
+
entry,
|
|
677
|
+
[
|
|
678
|
+
`import { command, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
|
|
679
|
+
'class PlanRunner extends runner("plan") {}',
|
|
680
|
+
'class PlanScript extends script("plan", [PlanRunner]) {}',
|
|
681
|
+
'class PlanCommand extends command("plan", [PlanScript], ({ public: target }) => ({',
|
|
682
|
+
' plan: target({ desc: "plans", stdio: true }).exec(async function () {',
|
|
683
|
+
" process.stdout.write(JSON.stringify({ phases: 1 }));",
|
|
684
|
+
" }),",
|
|
685
|
+
"})) {}",
|
|
686
|
+
"await runCommands(PlanCommand);",
|
|
687
|
+
"",
|
|
688
|
+
].join("\n"),
|
|
689
|
+
);
|
|
690
|
+
const proc = Bun.spawn(["bun", entry, "plan"], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
691
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
692
|
+
new Response(proc.stdout).text(),
|
|
693
|
+
new Response(proc.stderr).text(),
|
|
694
|
+
proc.exited,
|
|
695
|
+
]);
|
|
696
|
+
expect(code).toBe(0);
|
|
697
|
+
expect(stdout).toBe('{"phases":1}');
|
|
698
|
+
expect(stderr).toContain("0.0.1-older (akanjs)");
|
|
699
|
+
}, 30_000);
|
|
700
|
+
});
|
package/executors.test.ts
CHANGED
|
@@ -429,6 +429,35 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
429
429
|
});
|
|
430
430
|
});
|
|
431
431
|
|
|
432
|
+
test("reads an explicit env file as Bun's own loader does, leaving inline comments out of the values", async () => {
|
|
433
|
+
const root = await makeTempRoot();
|
|
434
|
+
await writeFile(
|
|
435
|
+
path.join(root, ".env"),
|
|
436
|
+
[
|
|
437
|
+
"# the workspace env",
|
|
438
|
+
"AKAN_PUBLIC_SERVE_DOMAIN=example.com # prod domain",
|
|
439
|
+
'AKAN_PUBLIC_REPO_NAME="file repo" # note',
|
|
440
|
+
"AKAN_PUBLIC_ENV=develop#no space needed",
|
|
441
|
+
"export AKAN_WORKSPACE_ID='ws # kept' # dropped",
|
|
442
|
+
'AKAN_PUBLIC_APP_NAME="say \\"hi\\"\\nagain" # c',
|
|
443
|
+
'AKAN_WORKSPACE_ROOT="/unterminated # c',
|
|
444
|
+
"PORT_OFFSET=7",
|
|
445
|
+
"",
|
|
446
|
+
].join("\r\n"),
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toEqual({
|
|
450
|
+
appName: 'say \\"hi\\"\nagain',
|
|
451
|
+
workspaceRoot: '"/unterminated',
|
|
452
|
+
repoName: "file repo",
|
|
453
|
+
serveDomain: "example.com",
|
|
454
|
+
env: "develop",
|
|
455
|
+
portOffset: 7,
|
|
456
|
+
workspaceId: "ws # kept",
|
|
457
|
+
defaulted: [],
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
432
461
|
test("builds app command environment and prepareCommand artifacts", async () => {
|
|
433
462
|
const root = await makeTempRoot();
|
|
434
463
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
package/executors.ts
CHANGED
|
@@ -140,6 +140,14 @@ export const execEmoji = {
|
|
|
140
140
|
default: "✈️",
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
+
//? As Bun's own .env loader reads it (1.4.4), which fills process.env too: `#` ends an unquoted value even mid-word.
|
|
144
|
+
const parseEnvValue = (value: string) => {
|
|
145
|
+
const [, quote, inner] = value.match(/^(["'`])((?:\\.|(?!\1)[^\\])*)\1/) ?? [];
|
|
146
|
+
if (!quote) return value.replace(/#.*/, "").trim();
|
|
147
|
+
if (quote !== '"') return inner;
|
|
148
|
+
return inner.replace(/\\(.)/g, (pair, char: string) => (char === "n" ? "\n" : char === "r" ? "\r" : pair));
|
|
149
|
+
};
|
|
150
|
+
|
|
143
151
|
const parseEnvFile = (envPath: string): Record<string, string> => {
|
|
144
152
|
const env: Record<string, string> = {};
|
|
145
153
|
const content = (() => {
|
|
@@ -156,11 +164,7 @@ const parseEnvFile = (envPath: string): Record<string, string> => {
|
|
|
156
164
|
const separatorIndex = normalized.indexOf("=");
|
|
157
165
|
if (separatorIndex <= 0) continue;
|
|
158
166
|
const key = normalized.slice(0, separatorIndex).trim();
|
|
159
|
-
|
|
160
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
161
|
-
value = value.slice(1, -1);
|
|
162
|
-
}
|
|
163
|
-
env[key] = value;
|
|
167
|
+
env[key] = parseEnvValue(normalized.slice(separatorIndex + 1).trim());
|
|
164
168
|
}
|
|
165
169
|
return env;
|
|
166
170
|
};
|
|
@@ -17,8 +17,29 @@ export const A = () => {
|
|
|
17
17
|
const [l] = open ? st.useState("l", String).desc("d").init("") : []; // @flag
|
|
18
18
|
if (open) st.expose("m", String).desc("d").value("x"); // @flag
|
|
19
19
|
const save = st.tool("z").desc("d").exec(async () => { st.use.n(); }); // @flag
|
|
20
|
+
const { title = st.use.title() } = props; // @flag
|
|
21
|
+
let cached = initial; cached ||= st.use.s(); // @flag
|
|
22
|
+
cached ??= st.use.t(); // @flag
|
|
23
|
+
cached &&= st.use.u(); // @flag
|
|
24
|
+
for (let i = 0; i < st.use.count(); i++) run(i); // @flag
|
|
25
|
+
const formatted = props.format?.(st.use.v()); // @flag
|
|
26
|
+
const chained = props.api?.format(st.use.w()); // @flag
|
|
27
|
+
const computed = open ? st["use"].x() : null; // @flag
|
|
28
|
+
const computedKey = open ? st.use["y"]() : null; // @flag
|
|
29
|
+
const computedSlice = open ? st.slice["inOrg"].use.z() : null; // @flag
|
|
30
|
+
useEffect(() => { void (async () => { await load(); st.use.aa(); })(); }, []); // @flag
|
|
20
31
|
return <div onClick={() => st.use.o()} />; // @flag
|
|
21
32
|
};
|
|
22
33
|
export const B = () => <Load.Units renderList={(list) => <X a={st.use.p()} />} />; // @flag
|
|
23
34
|
export const C = () => <Load.Stream of={q}>{(v) => <X a={st.sel((s) => s.q)} />}</Load.Stream>; // @flag
|
|
24
35
|
export const useD = () => useMemo(() => st.use.r(), []); // @flag
|
|
36
|
+
export const E = ({ name = st.use.defaultName() }) => <p>{name}</p>; // @flag
|
|
37
|
+
export function F({ name = st.sel((s) => s.name) }) { return <p>{name}</p>; } // @flag
|
|
38
|
+
export const G = () => {
|
|
39
|
+
const renderRow = (item) => <X a={st.use.bb()} />; // @flag
|
|
40
|
+
return <Load.Units renderItem={renderRow} />;
|
|
41
|
+
};
|
|
42
|
+
export const H = () => {
|
|
43
|
+
function row(item) { return <X a={st.ref((s) => s.cc)} />; } // @flag
|
|
44
|
+
return <ul>{items.map(row)}</ul>;
|
|
45
|
+
};
|
|
@@ -20,3 +20,14 @@ export const C = forwardRef((props, ref) => { const b = st.sel((s) => s.b); retu
|
|
|
20
20
|
export const useTools = () => { const tool = (name) => st.tool(name).desc("d").exec(() => run()); tool("a"); }; // @ok
|
|
21
21
|
export const D = () => <Load.Units renderItem={(item) => <Row key={item.id} id={item.id} />} />; // @ok
|
|
22
22
|
export const E = () => { const busy = open && other.use.x(); return busy; }; // @ok
|
|
23
|
+
export const F = () => { const Submit = useMemo(() => () => { const v = st.use.x(); return v; }, []); return <Submit />; }; // @ok
|
|
24
|
+
export const G = () => { const Submit = useMemo(() => (open ? () => null : () => { const save = st.tool("s").desc("d").exec(run); return <Button onClick={save} />; }), [open]); return <Submit />; }; // @ok
|
|
25
|
+
export const H = () => { for (let i = st.use.start(); i < 3; i++) run(i); return null; }; // @ok
|
|
26
|
+
export const I = isNative ? () => { const a = st.use.a(); return a; } : () => null; // @ok
|
|
27
|
+
export const J = () => { const a = (() => st.use.x())(); return a; }; // @ok
|
|
28
|
+
export const K = () => { const Row = () => { const a = st.use.a(); return a; }; return <List row={Row} />; }; // @ok
|
|
29
|
+
const renderPage = () => { const a = st.use.a(); return a; }; // @ok
|
|
30
|
+
export const L = page().render(renderPage); // @ok
|
|
31
|
+
export const M = () => { const v = st.use.x(); props.onPick?.(v); return v; }; // @ok
|
|
32
|
+
export const N = () => { const a = st["use"].a(); const b = st.use["b"](); return a ?? b; }; // @ok
|
|
33
|
+
export const O = () => { let v = cached; v = st.use.a(); return v; }; // @ok
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
engine biome(1.0)
|
|
2
2
|
language js(typescript, jsx)
|
|
3
3
|
|
|
4
|
-
// `st.use.<key>()`, `st.sel()`, `st.ref()` and the builders' last step
|
|
5
|
-
// `st.useState(…)….init()`, `st.expose(…)….value()`) are React hooks that Biome's
|
|
6
|
-
// it recognises a hook by a `use` prefix on the called name. A hook a render skips
|
|
7
|
-
// slot, and production React does not check the order — a later `useRef` then
|
|
8
|
-
// the screen dies far from the cause ("Cannot add property current, object is not
|
|
4
|
+
// `st.use.<key>()`, `st.slice.<name>.use.<key>()`, `st.sel()`, `st.ref()` and the builders' last step
|
|
5
|
+
// (`st.tool(…)….exec()` / `.card()`, `st.useState(…)….init()`, `st.expose(…)….value()`) are React hooks that Biome's
|
|
6
|
+
// `useHookAtTopLevel` cannot see: it recognises a hook by a `use` prefix on the called name. A hook a render skips
|
|
7
|
+
// shifts every later hook by one slot, and production React does not check the order — a later `useRef` then
|
|
8
|
+
// receives a frozen store value and the screen dies far from the cause ("Cannot add property current, object is not
|
|
9
|
+
// extensible").
|
|
9
10
|
//
|
|
10
|
-
// Reported: the right operand of `||` / `&&` /
|
|
11
|
-
//
|
|
12
|
-
// an
|
|
13
|
-
//
|
|
11
|
+
// Reported: the right operand of `||` / `&&` / `??` and of `||=` / `&&=` / `??=`, a ternary branch, an `if` / `else`
|
|
12
|
+
// body, a `switch` case, a loop (a `for` initializer runs once and is not one), a `catch` block, a default value,
|
|
13
|
+
// the arguments of an optional call, and a callback handed to a call or a JSX prop inside a component, inline or by
|
|
14
|
+
// a camelCase name (`.map`, `useEffect`, an event handler, a render prop — `Load.Units` calls `renderList` only while
|
|
15
|
+
// the list is non-empty). A test position is not reported: `if (st.use.x())` always runs. Each position counts up to
|
|
16
|
+
// the nearest function only, so a component defined in `useMemo` or picked by a module-level ternary is its own
|
|
17
|
+
// render; a PascalCase name is a component, which React renders rather than calls.
|
|
14
18
|
//
|
|
15
19
|
// Not reported: a hook after an early `return` (GritQL sees no statement order), a local helper the component
|
|
16
20
|
// calls (`const tool = (name) => st.tool(name)…exec(…)` runs a fixed number of times when called at top level),
|
|
@@ -25,7 +29,15 @@ pattern st_hook_call() {
|
|
|
25
29
|
`$chain.exec($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
|
|
26
30
|
`$chain.card($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
|
|
27
31
|
`$chain.init($args)` where { $chain <: r"st\s*\.\s*useState\s*\([\s\S]*" },
|
|
28
|
-
`$chain.value($args)` where { $chain <: r"st\s*\.\s*expose\s*\([\s\S]*" }
|
|
32
|
+
`$chain.value($args)` where { $chain <: r"st\s*\.\s*expose\s*\([\s\S]*" },
|
|
33
|
+
// Computed access (`st["use"].x()`, `st.use[key]()`) at any step, which would take a snippet per combination.
|
|
34
|
+
JsCallExpression(callee = $callee) where {
|
|
35
|
+
$callee <: or {
|
|
36
|
+
r"st\s*(?:\.\s*use|\[\s*[\x27\x22`]use[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])",
|
|
37
|
+
r"st\s*(?:\.\s*slice|\[\s*[\x27\x22`]slice[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])\s*(?:\.\s*use|\[\s*[\x27\x22`]use[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])",
|
|
38
|
+
r"st\s*\[\s*[\x27\x22`](?:sel|ref)[\x27\x22`]\s*\]"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
29
41
|
}
|
|
30
42
|
}
|
|
31
43
|
|
|
@@ -34,36 +46,95 @@ pattern function_like() {
|
|
|
34
46
|
JsArrowFunctionExpression(),
|
|
35
47
|
JsFunctionExpression(),
|
|
36
48
|
JsFunctionDeclaration(),
|
|
49
|
+
JsFunctionExportDefaultDeclaration(),
|
|
37
50
|
JsMethodObjectMember(),
|
|
38
|
-
|
|
51
|
+
JsGetterObjectMember(),
|
|
52
|
+
JsSetterObjectMember(),
|
|
53
|
+
JsMethodClassMember(),
|
|
54
|
+
JsGetterClassMember(),
|
|
55
|
+
JsSetterClassMember(),
|
|
56
|
+
JsConstructorClassMember()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// An IIFE runs where it stands, so the position around it still decides whether its hooks run.
|
|
61
|
+
pattern function_boundary() {
|
|
62
|
+
function_like() as $boundary where {
|
|
63
|
+
not $boundary <: within JsCallExpression(callee = $iifeCallee) where {
|
|
64
|
+
$iifeCallee <: or { $boundary, JsParenthesizedExpression(expression = $boundary) }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pattern callback_slot() {
|
|
70
|
+
or { JsCallArguments(), JsxExpressionAttributeValue(), JsxExpressionChild() }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pattern passed_by_name($name) {
|
|
74
|
+
or {
|
|
75
|
+
JsCallArguments(args = [..., $arg where { $arg <: $name }, ...]),
|
|
76
|
+
JsxExpressionAttributeValue(expression = $arg) where { $arg <: $name },
|
|
77
|
+
JsxExpressionChild(expression = $arg) where { $arg <: $name }
|
|
39
78
|
}
|
|
40
79
|
}
|
|
41
80
|
|
|
42
81
|
JsCallExpression() as $call where {
|
|
43
82
|
$call <: st_hook_call(),
|
|
44
83
|
or {
|
|
45
|
-
$call <: within JsLogicalExpression(right = $right) where { $right <: contains $call },
|
|
84
|
+
$call <: within JsLogicalExpression(right = $right) where { $right <: contains $call } until function_boundary(),
|
|
85
|
+
// The operator token is not a field GritQL can read; `[^=]*` makes the first `=` in the text the operator's.
|
|
86
|
+
$call <: within JsAssignmentExpression(right = $right) as $assignment where {
|
|
87
|
+
$assignment <: r"[^=]*(?:\|\||&&|\?\?)=[\s\S]*",
|
|
88
|
+
$right <: contains $call
|
|
89
|
+
} until function_boundary(),
|
|
46
90
|
$call <: within JsConditionalExpression(consequent = $then, alternate = $else) where {
|
|
47
91
|
or { $then <: contains $call, $else <: contains $call }
|
|
48
|
-
},
|
|
92
|
+
} until function_boundary(),
|
|
49
93
|
$call <: within JsIfStatement(consequent = $then, else_clause = $else) where {
|
|
50
94
|
or { $then <: contains $call, $else <: contains $call }
|
|
51
|
-
},
|
|
52
|
-
$call <: within
|
|
53
|
-
$call <: within
|
|
95
|
+
} until function_boundary(),
|
|
96
|
+
$call <: within JsForStatement(initializer = $init) where { not $init <: contains $call } until function_boundary(),
|
|
97
|
+
$call <: within JsForOfStatement(body = $body) where { $body <: contains $call } until function_boundary(),
|
|
98
|
+
$call <: within JsForInStatement(body = $body) where { $body <: contains $call } until function_boundary(),
|
|
54
99
|
$call <: within or {
|
|
55
100
|
JsCaseClause(),
|
|
56
101
|
JsDefaultClause(),
|
|
57
|
-
JsForStatement(),
|
|
58
102
|
JsWhileStatement(),
|
|
59
103
|
JsDoWhileStatement(),
|
|
60
104
|
JsCatchClause()
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
105
|
+
} until function_boundary(),
|
|
106
|
+
$call <: within or {
|
|
107
|
+
JsFormalParameter(initializer = $default),
|
|
108
|
+
JsObjectBindingPatternShorthandProperty(init = $default),
|
|
109
|
+
JsObjectBindingPatternProperty(init = $default),
|
|
110
|
+
JsArrayBindingPatternElement(init = $default),
|
|
111
|
+
JsObjectAssignmentPatternShorthandProperty(init = $default),
|
|
112
|
+
JsObjectAssignmentPatternProperty(init = $default),
|
|
113
|
+
JsArrayAssignmentPatternElement(init = $default)
|
|
114
|
+
} where { $default <: contains $call } until function_boundary(),
|
|
115
|
+
// A `?.` ahead of the callee's first `(` or `[` sits on its own chain, so a nullish link skips the arguments.
|
|
116
|
+
$call <: within JsCallExpression(callee = $callee, arguments = $arguments) as $outerCall where {
|
|
117
|
+
$arguments <: contains $call,
|
|
118
|
+
or { $outerCall <: `$optionalCallee?.($optionalArgs)`, $callee <: r"[^\[\(]*\?\.[\s\S]*" }
|
|
119
|
+
} until function_boundary(),
|
|
120
|
+
and {
|
|
121
|
+
$call <: within function_boundary() as $nearest until function_boundary(),
|
|
122
|
+
or {
|
|
123
|
+
// The callback must be handed over inside another function: a component passed to `memo()` /
|
|
124
|
+
// `forwardRef()` at module level is not one.
|
|
125
|
+
$nearest <: within callback_slot() as $slot where { $slot <: within function_boundary() } until function_boundary(),
|
|
126
|
+
and {
|
|
127
|
+
or {
|
|
128
|
+
$nearest <: JsFunctionDeclaration(id = $name),
|
|
129
|
+
$nearest <: within JsVariableDeclarator(id = $name, initializer = JsInitializerClause(expression = $value)) where {
|
|
130
|
+
$value <: $nearest
|
|
131
|
+
} until function_boundary()
|
|
132
|
+
},
|
|
133
|
+
$name <: r"[a-z_$][\w$]*",
|
|
134
|
+
$nearest <: within or { JsFunctionBody(), JsModule() } as $scope where {
|
|
135
|
+
$scope <: contains passed_by_name($name) as $handover where { $handover <: within function_boundary() }
|
|
136
|
+
} until or { JsFunctionBody(), JsModule() }
|
|
137
|
+
}
|
|
67
138
|
}
|
|
68
139
|
}
|
|
69
140
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.0-rc.
|
|
3
|
+
"version": "3.1.0-rc.27",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
49
|
"@inquirer/prompts": "^8.7.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.0-rc.
|
|
51
|
+
"akanjs": "3.1.0-rc.27",
|
|
52
52
|
"chalk": "^6.0.1",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"dayjs": "^1.11.23",
|
package/subspace.test.ts
CHANGED
|
@@ -245,7 +245,7 @@ describe("Subspace push", () => {
|
|
|
245
245
|
expect(second.sha).not.toBe(first.sha);
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
test("keeps the subspace's own root config and
|
|
248
|
+
test("keeps the subspace's own root config and a Jenkinsfile an earlier push left there, never the workspace's", async () => {
|
|
249
249
|
const { workRoot, bareRoot, subspace } = await makeMirror("own-served", "own-private", "own-kit");
|
|
250
250
|
const customer = await cloneSubspace(workRoot, bareRoot, "own-customer");
|
|
251
251
|
await write(path.join(customer, "akan.config.ts"), "export default { pipeline: {} };\n");
|
|
@@ -261,7 +261,8 @@ describe("Subspace push", () => {
|
|
|
261
261
|
expect(await FileSys.readText(path.join(subspaceRoot, "akan.config.ts"))).toContain(
|
|
262
262
|
"export default { pipeline: {} };",
|
|
263
263
|
);
|
|
264
|
-
expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(
|
|
264
|
+
expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(true);
|
|
265
|
+
expect(await FileSys.readText(path.join(subspaceRoot, "Jenkinsfile"))).toContain("pipeline { agent none }");
|
|
265
266
|
});
|
|
266
267
|
|
|
267
268
|
test("refuses a lock that resolves from a registry only this machine reaches, before it clones", async () => {
|
package/subspaceClone.ts
CHANGED
|
@@ -47,10 +47,6 @@ export class SubspaceClone {
|
|
|
47
47
|
this.#clone = new Executor(`subspace-${declaration.name}`, this.#clonePath);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
async #cloneGit(args: string[]) {
|
|
51
|
-
return await this.#workspace.spawn("git", ["-C", this.#clonePath, ...args]);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
50
|
async #ensure(branch: string) {
|
|
55
51
|
if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
|
|
56
52
|
await rm(this.#clonePath, { recursive: true, force: true });
|
|
@@ -160,15 +156,13 @@ export class SubspaceClone {
|
|
|
160
156
|
await rm(path.join(this.#clonePath, SubspaceClone.holdDir), { recursive: true, force: true });
|
|
161
157
|
}
|
|
162
158
|
|
|
163
|
-
//*
|
|
164
|
-
//* it goes. Any other is the subspace's own and is held through the extraction.
|
|
159
|
+
//* Held even when an earlier push left it, the workspace's Jenkinsfile among them: it is the subspace's only copy.
|
|
165
160
|
async #holdRootEntries() {
|
|
166
161
|
const holdRoot = path.join(this.#clonePath, SubspaceClone.holdDir, "root");
|
|
167
162
|
const held: string[] = [];
|
|
168
163
|
for (const entry of SubspaceClone.subspaceOwnedRootEntries) {
|
|
169
164
|
const from = path.join(this.#clonePath, entry);
|
|
170
165
|
if (!(await FileSys.entryExists(from))) continue;
|
|
171
|
-
if (await this.#isPushedCopy(entry)) continue;
|
|
172
166
|
await mkdir(holdRoot, { recursive: true });
|
|
173
167
|
await rename(from, path.join(holdRoot, entry));
|
|
174
168
|
held.push(entry);
|
|
@@ -185,11 +179,6 @@ export class SubspaceClone {
|
|
|
185
179
|
}
|
|
186
180
|
}
|
|
187
181
|
|
|
188
|
-
async #isPushedCopy(entry: string) {
|
|
189
|
-
const subject = await this.#cloneGit(["log", "-1", "--format=%s", "--", entry]).catch(() => "");
|
|
190
|
-
return subject.trim().startsWith(SubspaceClone.syncSubjectPrefix);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
182
|
static async #copyMissing(from: string, to: string) {
|
|
194
183
|
await mkdir(to, { recursive: true });
|
|
195
184
|
for (const entry of await readdir(from, { withFileTypes: true })) {
|