@hajimism/dek 0.3.2 → 0.4.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/CHANGELOG.md +90 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +2 -1
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +84 -14
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +54 -13
- package/src/cli/contract.ts +18 -1
- package/src/cli/files.ts +9 -6
- package/src/cli/flags.ts +1 -1
- package/src/cli/goto.ts +3 -1
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +4 -3
- package/src/cli/main.ts +7 -5
- package/src/cli/new.ts +41 -10
- package/src/cli/pack.ts +21 -0
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +243 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +25 -27
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +45 -4
- package/src/cli/theme.ts +16 -2
- package/src/cli/voice.ts +4 -3
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/config-keys.ts +6 -2
- package/src/core/config.ts +88 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/licenses/CC-BY-4.0.txt +396 -0
- package/src/core/licenses/CC0-1.0.txt +121 -0
- package/src/core/licenses.ts +20 -0
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +6 -2
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +3 -1
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +30 -10
- package/src/core/pack.ts +137 -0
- package/src/core/playwright-module.d.ts +1 -0
- package/src/core/playwright-visual.ts +180 -20
- package/src/core/playwright.ts +202 -25
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +55 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +100 -17
- package/src/core/ref.ts +318 -34
- package/src/core/resolve.ts +20 -9
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +25 -17
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +10 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/sync.ts +3 -3
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +154 -15
- package/src/core/voice.ts +3 -5
- package/src/core/zod.ts +9 -1
- package/src/runtime/annotate-view.ts +4 -2
- package/src/server/dev.ts +15 -4
- package/src/server/lock.ts +48 -5
- package/src/server/routes.ts +7 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
package/src/cli/init.ts
CHANGED
|
@@ -3,10 +3,12 @@ import { basename, join, resolve } from "node:path";
|
|
|
3
3
|
import { parseCss, publishedTokens } from "../core/css.ts";
|
|
4
4
|
import { deckPaths } from "../core/deck-paths.ts";
|
|
5
5
|
import { DekError } from "../core/error.ts";
|
|
6
|
+
import { shellQuote } from "../core/escape.ts";
|
|
6
7
|
import { REQUIRED_TOKENS } from "../core/lint/tokens.ts";
|
|
7
8
|
import { walkUp } from "../core/optional.ts";
|
|
8
9
|
import { DECK_NAME_HINT, isDeckName } from "../core/path.ts";
|
|
9
|
-
import {
|
|
10
|
+
import { refuseInsideRef } from "../core/ref.ts";
|
|
11
|
+
import { readSourceIfExists } from "../core/safe-fs.ts";
|
|
10
12
|
import {
|
|
11
13
|
defaultTsconfig,
|
|
12
14
|
dekFilePaths,
|
|
@@ -25,7 +27,6 @@ import {
|
|
|
25
27
|
nextSteps,
|
|
26
28
|
type PlannedPath,
|
|
27
29
|
playwrightStep,
|
|
28
|
-
shellQuote,
|
|
29
30
|
} from "./files.ts";
|
|
30
31
|
|
|
31
32
|
export type InitResult = {
|
|
@@ -84,7 +85,7 @@ export function initCommand(options: { cwd: string; dir?: string; deck?: string
|
|
|
84
85
|
/** The tokens every deck needs that the project's theme.css does not publish on `.slide`. */
|
|
85
86
|
function missingThemeTokens(root: string): string[] {
|
|
86
87
|
const published = new Set(
|
|
87
|
-
publishedTokens(parseCss(
|
|
88
|
+
publishedTokens(parseCss(readSourceIfExists(join(root, "theme.css"), root) ?? "")).map(
|
|
88
89
|
(token) => token.name,
|
|
89
90
|
),
|
|
90
91
|
);
|
|
@@ -100,8 +101,10 @@ function syncDeckFiles(deckDir: string, created: string[]): FileChanges {
|
|
|
100
101
|
/** Everything init writes but `.dek/` and AGENTS.md, in the order a reader meets it. */
|
|
101
102
|
function projectPlan(root: string, deck: string | undefined): PlannedPath[] {
|
|
102
103
|
const themePath = join(root, "theme.css");
|
|
103
|
-
// A first deck is drawn in the theme that will be there: the one kept, or dek's.
|
|
104
|
-
|
|
104
|
+
// A first deck is drawn in the theme that will be there: the one kept, or dek's. A folder that
|
|
105
|
+
// is not there yet holds no theme.
|
|
106
|
+
const theme =
|
|
107
|
+
(existsSync(root) ? readSourceIfExists(themePath, root) : undefined) ?? defaultTheme();
|
|
105
108
|
return [
|
|
106
109
|
{ path: join(root, "dek.toml"), contents: defaultToml() },
|
|
107
110
|
{ path: themePath, contents: defaultTheme() },
|
|
@@ -123,6 +126,8 @@ function checkTarget(root: string): void {
|
|
|
123
126
|
if (existsSync(root) && !statSync(root).isDirectory()) {
|
|
124
127
|
throw new DekError("not a directory", { path: root, hint: "pass a directory to init" });
|
|
125
128
|
}
|
|
129
|
+
// A ref is someone else's project, read where it is: init would write its files into it.
|
|
130
|
+
refuseInsideRef(root);
|
|
126
131
|
const outer = walkUp(root, (dir) => {
|
|
127
132
|
const config = join(dir, "dek.toml");
|
|
128
133
|
return existsSync(config) && statSync(config).isFile() ? dir : undefined;
|
package/src/cli/lint.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { relative } from "node:path";
|
|
3
3
|
import type { Diagnostic } from "../core/diagnostic.ts";
|
|
4
|
+
import { shellQuote } from "../core/escape.ts";
|
|
4
5
|
import { lintDeck, lintProject, unreadableScriptDiagnostics } from "../core/lint.ts";
|
|
5
6
|
import { playwrightMissingError } from "../core/playwright.ts";
|
|
6
7
|
import { resolveRumdlBin, runRumdl } from "../core/rumdl.ts";
|
|
7
8
|
import { mergeSarif, type SarifLog } from "../core/sarif.ts";
|
|
8
9
|
import { syncDeck } from "../core/sync.ts";
|
|
9
10
|
import { lintVisualDeck } from "../core/visual.ts";
|
|
10
|
-
import { type SkippedCheck, skippedChecks, visualSkipped } from "./result.ts";
|
|
11
|
+
import { RENDERED_RULES, type SkippedCheck, skippedChecks, visualSkipped } from "./result.ts";
|
|
11
12
|
import type { DecksTarget } from "./scope.ts";
|
|
12
13
|
|
|
13
14
|
export type LintCliResult = {
|
|
@@ -60,7 +61,7 @@ export async function lintCommand(
|
|
|
60
61
|
? []
|
|
61
62
|
: [
|
|
62
63
|
visualSkipped("dekc lint --visual", {
|
|
63
|
-
reason:
|
|
64
|
+
reason: `${RENDERED_RULES} are measured only with --visual`,
|
|
64
65
|
}),
|
|
65
66
|
]),
|
|
66
67
|
]),
|
|
@@ -79,6 +80,6 @@ function rumdlSkipped(scriptPath: string, cwd: string): SkippedCheck {
|
|
|
79
80
|
return {
|
|
80
81
|
check: "rumdl",
|
|
81
82
|
reason: "rumdl ran but gave no result",
|
|
82
|
-
hint: `run \`${bin} check ${relative(cwd, scriptPath) || scriptPath}\` to see why`,
|
|
83
|
+
hint: `run \`${shellQuote(bin)} check ${shellQuote(relative(cwd, scriptPath) || scriptPath)}\` to see why`,
|
|
83
84
|
};
|
|
84
85
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DekError } from "../core/error.ts";
|
|
2
|
+
import { shellQuote } from "../core/escape.ts";
|
|
2
3
|
import { either } from "../core/prose.ts";
|
|
3
4
|
import { isRefName } from "../core/ref-name.ts";
|
|
4
5
|
import { resolveProject } from "../core/resolve.ts";
|
|
5
|
-
import { addressHint } from "./address.ts";
|
|
6
|
+
import { addressHint, deckWord, type HintDeck, withPlaceholderNote } from "./address.ts";
|
|
6
7
|
import {
|
|
7
8
|
type AnySpec,
|
|
8
9
|
type CliResult,
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
subcommandAt,
|
|
14
15
|
subcommandsOf,
|
|
15
16
|
} from "./commands.ts";
|
|
16
|
-
import { shellQuote } from "./files.ts";
|
|
17
17
|
import { type CommandLine, parseCommandLine, wantsJson } from "./flags.ts";
|
|
18
18
|
import { writeFailure, writeSuccess } from "./result.ts";
|
|
19
19
|
import { DeckRequiredError, namesDeck, peelDeckArg, resolveTarget } from "./scope.ts";
|
|
@@ -197,12 +197,14 @@ function retypedWithDeck<T>(line: CommandLine, cwd: string, resolve: () => T): T
|
|
|
197
197
|
}
|
|
198
198
|
const typed = `\`dekc ${line.typed.map(shellQuote).join(" ")}\``;
|
|
199
199
|
const [first] = error.decks;
|
|
200
|
+
// Each command is retyped for one deck; where to find a name left as <deck> is said once.
|
|
201
|
+
const retyped = (deck: HintDeck) => addressHint(typed, deck, cwd, { note: false });
|
|
200
202
|
const hint = !first
|
|
201
203
|
? "run `dekc new <name>` to make a deck"
|
|
202
204
|
: error.decks.length > NAMED_DECKS
|
|
203
|
-
? `run ${
|
|
204
|
-
: `run ${either(error.decks.map(
|
|
205
|
-
throw new DekError(error.message, { hint, cause: error });
|
|
205
|
+
? `run ${retyped(first)}, or name another deck in place of ${deckWord(first.name)}; \`dekc ls\` lists them`
|
|
206
|
+
: `run ${either(error.decks.map(retyped))}`;
|
|
207
|
+
throw new DekError(error.message, { hint: withPlaceholderNote(hint), cause: error });
|
|
206
208
|
}
|
|
207
209
|
}
|
|
208
210
|
|
package/src/cli/new.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadConfig } from "../core/config.ts";
|
|
4
4
|
import { deckPaths } from "../core/deck-paths.ts";
|
|
5
5
|
import { DekError } from "../core/error.ts";
|
|
6
6
|
import { DECK_NAME_HINT, isDeckName } from "../core/path.ts";
|
|
7
|
+
import { isRefName } from "../core/ref-name.ts";
|
|
8
|
+
import { readDeckFile } from "../core/resolve.ts";
|
|
9
|
+
import { readSourceIfExists } from "../core/safe-fs.ts";
|
|
7
10
|
import { syncDeck } from "../core/sync.ts";
|
|
8
11
|
import { applyPlan, deckPlan, nextSteps } from "./files.ts";
|
|
9
|
-
import { requireDeckFromCwd, requireProject } from "./scope.ts";
|
|
12
|
+
import { type RefInfo, requireDeckFromCwd, requireProject, resolveTarget } from "./scope.ts";
|
|
10
13
|
|
|
11
14
|
export type NewResult = {
|
|
12
15
|
name: string;
|
|
@@ -16,6 +19,8 @@ export type NewResult = {
|
|
|
16
19
|
updated: string[];
|
|
17
20
|
/** The commands to run next, from the directory new ran in. */
|
|
18
21
|
next: string[];
|
|
22
|
+
/** Set when the theme came from a ref with no license. */
|
|
23
|
+
warnings: string[];
|
|
19
24
|
};
|
|
20
25
|
|
|
21
26
|
export function newCommand(options: { cwd: string; name: string; themeFrom?: string }): NewResult {
|
|
@@ -35,19 +40,34 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
|
|
|
35
40
|
});
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
// A ref is read where it is, never written: only the copy below is the new deck's.
|
|
44
|
+
const fromRef =
|
|
45
|
+
options.themeFrom !== undefined && isRefName(options.themeFrom)
|
|
46
|
+
? resolveTarget(options.cwd, "deck", { refs: true, deck: options.themeFrom })
|
|
47
|
+
: undefined;
|
|
48
|
+
const ref = fromRef?.ref;
|
|
49
|
+
const fromDeck = fromRef
|
|
50
|
+
? fromRef.deck
|
|
51
|
+
: options.themeFrom
|
|
52
|
+
? requireDeckFromCwd(options.cwd, options.themeFrom).deck
|
|
53
|
+
: undefined;
|
|
54
|
+
const themeSource = fromDeck ? deckPaths(fromDeck.dir).theme : join(project.root, "theme.css");
|
|
55
|
+
// What is copied is published with the new deck: a link out of its project is refused.
|
|
56
|
+
const source = fromDeck
|
|
57
|
+
? readDeckFile(fromDeck.dir, themeSource)
|
|
58
|
+
: readSourceIfExists(themeSource, project.root);
|
|
59
|
+
if (source === undefined) {
|
|
42
60
|
throw new DekError("theme.css not found", {
|
|
43
61
|
path: themeSource,
|
|
44
|
-
hint:
|
|
45
|
-
? "run `dekc
|
|
46
|
-
:
|
|
62
|
+
hint: ref
|
|
63
|
+
? "run `dekc ref` to list the refs, and pick one whose deck has theme.css"
|
|
64
|
+
: options.themeFrom
|
|
65
|
+
? "run `dekc ls` and pick a deck that has theme.css"
|
|
66
|
+
: "add theme.css at the project root",
|
|
47
67
|
});
|
|
48
68
|
}
|
|
49
69
|
|
|
50
|
-
const theme =
|
|
70
|
+
const theme = `${ref ? provenance(ref) : ""}${source}`;
|
|
51
71
|
const { created } = applyPlan(
|
|
52
72
|
deckPlan(project.root, name, theme, loadConfig(project.configPath)),
|
|
53
73
|
);
|
|
@@ -61,5 +81,16 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
|
|
|
61
81
|
created,
|
|
62
82
|
updated: synced.dekFiles.updated,
|
|
63
83
|
next: nextSteps(options.cwd, project.root, dir),
|
|
84
|
+
warnings:
|
|
85
|
+
ref && ref.license === null
|
|
86
|
+
? [
|
|
87
|
+
`${ref.name} has no license; ask its author before you present or share a deck built on its theme`,
|
|
88
|
+
]
|
|
89
|
+
: [],
|
|
64
90
|
};
|
|
65
91
|
}
|
|
92
|
+
|
|
93
|
+
/** The first line of a theme copied from a ref: where it came from, for its reader and its credit. */
|
|
94
|
+
function provenance(ref: RefInfo): string {
|
|
95
|
+
return `/* Theme from the ref ${ref.name} at ${ref.rev.slice(0, 7)}. */\n`;
|
|
96
|
+
}
|
package/src/cli/pack.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { writePack } from "../core/pack.ts";
|
|
2
|
+
import type { DecksTarget } from "./scope.ts";
|
|
3
|
+
|
|
4
|
+
/** One file per deck, a single deck included, as `dekc pdf` answers, and what to fix first. */
|
|
5
|
+
export type PackCliResult = { outs: string[]; warnings: string[] };
|
|
6
|
+
|
|
7
|
+
export async function packCommand(
|
|
8
|
+
{ project, decks }: DecksTarget,
|
|
9
|
+
options: { rootDist?: boolean } = {},
|
|
10
|
+
): Promise<PackCliResult> {
|
|
11
|
+
const outs: string[] = [];
|
|
12
|
+
const warnings = new Set<string>();
|
|
13
|
+
for (const entry of decks) {
|
|
14
|
+
const done = await writePack(project, entry, { rootDist: options.rootDist });
|
|
15
|
+
outs.push(done.outPath);
|
|
16
|
+
for (const warning of done.warnings) {
|
|
17
|
+
warnings.add(warning);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return { outs, warnings: [...warnings] };
|
|
21
|
+
}
|
package/src/cli/pptx.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
|
-
import { pptxDeck } from "../core/pptx.ts";
|
|
1
|
+
import { type PptxLifted, pptxDeck } from "../core/pptx.ts";
|
|
2
2
|
import type { DecksTarget } from "./scope.ts";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* One file per deck, a single deck included, as `dekc pdf` answers, and what each file laid
|
|
6
|
+
* over its pictures, for the text output alone (`--json` is `outs`).
|
|
7
|
+
*/
|
|
8
|
+
export type PptxCliResult = { outs: string[]; lifted?: PptxLifted[] };
|
|
6
9
|
|
|
7
10
|
export async function pptxCommand(
|
|
8
11
|
{ project, decks }: DecksTarget,
|
|
9
12
|
options: { rootDist?: boolean; public?: boolean } = {},
|
|
10
13
|
): Promise<PptxCliResult> {
|
|
11
14
|
const outs: string[] = [];
|
|
15
|
+
const lifted: PptxLifted[] = [];
|
|
12
16
|
for (const entry of decks) {
|
|
13
17
|
const done = await pptxDeck(
|
|
14
18
|
{ project, deck: entry },
|
|
15
19
|
{ rootDist: options.rootDist, public: options.public },
|
|
16
20
|
);
|
|
17
21
|
outs.push(done.outPath);
|
|
22
|
+
lifted.push(done.lifted);
|
|
18
23
|
}
|
|
19
|
-
return { outs };
|
|
24
|
+
return { outs, lifted };
|
|
20
25
|
}
|
package/src/cli/ref.ts
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { loadConfig } from "../core/config.ts";
|
|
4
4
|
import { DekError } from "../core/error.ts";
|
|
5
|
+
import { isPlainWord, shellQuote } from "../core/escape.ts";
|
|
5
6
|
import { downloadTarball, resolveRev } from "../core/github.ts";
|
|
6
7
|
import {
|
|
8
|
+
deckOfPack,
|
|
7
9
|
installSnapshot,
|
|
8
10
|
isPinnedRev,
|
|
11
|
+
packFile,
|
|
9
12
|
parseRefSource,
|
|
10
|
-
|
|
13
|
+
pickSnapshot,
|
|
14
|
+
readPack,
|
|
11
15
|
readTarball,
|
|
16
|
+
refDir,
|
|
12
17
|
refLicense,
|
|
13
18
|
refState,
|
|
14
19
|
refTitle,
|
|
20
|
+
refuseLinkUnderPacks,
|
|
21
|
+
refuseLinkUnderRefs,
|
|
22
|
+
type Snapshot,
|
|
23
|
+
snapshotDigest,
|
|
24
|
+
writeSnapshot,
|
|
15
25
|
} from "../core/ref.ts";
|
|
16
|
-
import {
|
|
17
|
-
import { readSourceIfExists, removeInside, writeInside } from "../core/safe-fs.ts";
|
|
26
|
+
import type { FileRefSource, GithubRefSource, RefSource } from "../core/ref-name.ts";
|
|
27
|
+
import { readSourceIfExists, removeInside, requireInside, writeInside } from "../core/safe-fs.ts";
|
|
18
28
|
import { writeAgentsMd } from "../core/sync.ts";
|
|
19
29
|
import { setTableKey } from "../core/toml-keys.ts";
|
|
20
30
|
import { requireProject } from "./scope.ts";
|
|
@@ -29,6 +39,8 @@ export type RefAddResult = {
|
|
|
29
39
|
changed: boolean;
|
|
30
40
|
dir: string;
|
|
31
41
|
license: string | null;
|
|
42
|
+
/** A pack's ref: its committed copy, which a clone unpacks the ref from. */
|
|
43
|
+
pack?: string;
|
|
32
44
|
warnings: string[];
|
|
33
45
|
};
|
|
34
46
|
|
|
@@ -49,23 +61,82 @@ export type RefCliResult = RefAddResult | RefListResult | RefRmResult;
|
|
|
49
61
|
export async function restoreRef(cwd: string, arg: string): Promise<void> {
|
|
50
62
|
const source = parseRefSource(arg);
|
|
51
63
|
const project = requireProject(cwd);
|
|
52
|
-
const { pinned, fetched } = refState(project, source.name);
|
|
64
|
+
const { pinned, dir, fetched } = refState(project, source.name);
|
|
53
65
|
if (pinned === undefined || fetched) {
|
|
54
66
|
return;
|
|
55
67
|
}
|
|
68
|
+
// Said before a download that the write would refuse anyway.
|
|
69
|
+
refuseLinkUnderRefs(project.root, dir);
|
|
70
|
+
if (source.kind === "file") {
|
|
71
|
+
await unpackSnapshot(project.root, source, pinned);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
56
74
|
await fetchSnapshot(project.root, source, pinned);
|
|
57
75
|
}
|
|
58
76
|
|
|
59
|
-
async function fetchSnapshot(root: string, source:
|
|
77
|
+
async function fetchSnapshot(root: string, source: GithubRefSource, sha: string) {
|
|
60
78
|
const files = await readTarball(await downloadTarball(source.owner, source.repo, sha));
|
|
61
79
|
return installSnapshot(root, source, sha, files);
|
|
62
80
|
}
|
|
63
81
|
|
|
82
|
+
/** A pack's snapshot, unpacked again from its committed copy, which must be the pinned one. */
|
|
83
|
+
async function unpackSnapshot(root: string, source: FileRefSource, pin: string): Promise<void> {
|
|
84
|
+
const rel = packFile(source.deck);
|
|
85
|
+
const path = join(root, rel);
|
|
86
|
+
if (!existsSync(path)) {
|
|
87
|
+
throw new DekError(`${rel} is missing`, {
|
|
88
|
+
path,
|
|
89
|
+
hint: `a pack's ref is unpacked from its copy in packs/, so commit packs/ with dek.toml; run \`dekc ref <file>.tgz\` with the pack you were sent to add it again`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const snapshot = await readPackFile(path, source, root);
|
|
93
|
+
if ((await snapshotDigest(snapshot)) !== pin) {
|
|
94
|
+
throw new DekError(`${rel} is not the pack ${source.name} is pinned to`, {
|
|
95
|
+
path,
|
|
96
|
+
hint: `run \`dekc ref ${shellQuote(rel)}\` to pin the pack that is there, or put back the one dek.toml pins`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
await writeSnapshot(root, source.name, pin, snapshot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What a snapshot keeps of the pack at `path`; an error says which file it was about. */
|
|
103
|
+
async function readPackFile(
|
|
104
|
+
path: string,
|
|
105
|
+
source: FileRefSource | undefined,
|
|
106
|
+
root?: string,
|
|
107
|
+
): Promise<Snapshot & { source: FileRefSource; bytes: Uint8Array }> {
|
|
108
|
+
try {
|
|
109
|
+
const bytes = readFileSync(root === undefined ? path : requireInside(path, root));
|
|
110
|
+
const files = await readPack(bytes);
|
|
111
|
+
const found = source ?? parseRefSource(`file/${deckOfPack(files)}`);
|
|
112
|
+
if (found.kind !== "file") {
|
|
113
|
+
throw new DekError("the pack's deck cannot be named as a ref");
|
|
114
|
+
}
|
|
115
|
+
return { ...pickSnapshot(files, found), source: found, bytes };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof DekError && error.path === undefined) {
|
|
118
|
+
throw new DekError(error.message, { path, ...(error.hint ? { hint: error.hint } : {}) });
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
64
124
|
/** `dekc ref <source>`: pin and fetch, or move the pin to the latest or to the rev after `@`. */
|
|
65
125
|
export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
|
|
126
|
+
if (isPackPath(arg)) {
|
|
127
|
+
return addPack(cwd, arg);
|
|
128
|
+
}
|
|
66
129
|
const source = parseRefSource(arg);
|
|
130
|
+
if (source.kind === "file") {
|
|
131
|
+
throw new DekError(`"${arg}" names a pack's ref`, {
|
|
132
|
+
hint: "add a pack by its file, `dekc ref <file>.tgz`; `dekc ref` lists the refs already added",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
67
135
|
const project = requireProject(cwd);
|
|
136
|
+
refuseSharedFolders(project, source);
|
|
68
137
|
const { pinned: from, dir, fetched } = refState(project, source.name);
|
|
138
|
+
// Said before a download that the write would refuse anyway.
|
|
139
|
+
refuseLinkUnderRefs(project.root, dir);
|
|
69
140
|
const rev =
|
|
70
141
|
source.rev !== undefined && isPinnedRev(source.rev)
|
|
71
142
|
? source.rev
|
|
@@ -78,7 +149,7 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
|
|
|
78
149
|
changed = true;
|
|
79
150
|
}
|
|
80
151
|
if (from !== rev) {
|
|
81
|
-
const toml =
|
|
152
|
+
const toml = readSourceIfExists(project.configPath, project.root) ?? "";
|
|
82
153
|
writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
|
|
83
154
|
changed = true;
|
|
84
155
|
}
|
|
@@ -103,6 +174,153 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
|
|
|
103
174
|
};
|
|
104
175
|
}
|
|
105
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Refuses a pin whose folders another pin, or a folder already on disk, takes. refs/ and packs/
|
|
179
|
+
* are named by the ref, and macOS and Windows read a name without its case, so `file/Talk` and
|
|
180
|
+
* `file/talk` would share one snapshot and one pack, and adding the second would replace the
|
|
181
|
+
* first's.
|
|
182
|
+
*/
|
|
183
|
+
function refuseSharedFolders(
|
|
184
|
+
project: { root: string; configPath: string },
|
|
185
|
+
source: RefSource,
|
|
186
|
+
): void {
|
|
187
|
+
const { name } = source;
|
|
188
|
+
const other = Object.keys(loadConfig(project.configPath).refs ?? {}).find(
|
|
189
|
+
(pinned) => pinned !== name && folded(pinned) === folded(name),
|
|
190
|
+
);
|
|
191
|
+
if (other !== undefined) {
|
|
192
|
+
throw new DekError(`${name} would share its folders with ${other}, which dek.toml pins`, {
|
|
193
|
+
path: project.configPath,
|
|
194
|
+
hint: `a ref's folders are named without their case on macOS and Windows; to pin ${name} in its place, run \`dekc ref rm ${shellQuote(other)}\` first`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const paths = [relative(project.root, refDir(project.root, name))];
|
|
198
|
+
if (source.kind === "file") {
|
|
199
|
+
paths.push(packFile(source.deck));
|
|
200
|
+
}
|
|
201
|
+
const left = paths
|
|
202
|
+
.map((path) => spelledOtherwise(project.root, path))
|
|
203
|
+
.find((path) => path !== undefined);
|
|
204
|
+
if (left !== undefined) {
|
|
205
|
+
const shown = isPlainWord(left) ? left : "another folder";
|
|
206
|
+
throw new DekError(
|
|
207
|
+
`${name} would share a folder with ${shown}, which is already in the project`,
|
|
208
|
+
{
|
|
209
|
+
path: join(project.root, left),
|
|
210
|
+
hint: "no ref in dek.toml pins it, so a ref removed by hand left it; delete it and add the ref again",
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A name as a file system blind to case and form reads it: APFS folds ς as σ, and fi as fi. */
|
|
217
|
+
function folded(name: string): string {
|
|
218
|
+
return name.normalize("NFKC").toUpperCase().toLowerCase();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The path, under `root`, of a folder or file a file system blind to case and form would read as
|
|
223
|
+
* `path` though it is spelled otherwise, found segment by segment.
|
|
224
|
+
*/
|
|
225
|
+
function spelledOtherwise(root: string, path: string): string | undefined {
|
|
226
|
+
let dir = root;
|
|
227
|
+
const walked: string[] = [];
|
|
228
|
+
for (const segment of path.split(sep)) {
|
|
229
|
+
const entries = existsSync(dir) && statSync(dir).isDirectory() ? readdirSync(dir) : [];
|
|
230
|
+
const other = entries.find((entry) => entry !== segment && folded(entry) === folded(segment));
|
|
231
|
+
if (other !== undefined) {
|
|
232
|
+
return join(...walked, other);
|
|
233
|
+
}
|
|
234
|
+
if (!entries.includes(segment)) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
walked.push(segment);
|
|
238
|
+
dir = join(dir, segment);
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** An argument that ends in .tgz or .tar.gz is a pack's path, never a ref name. */
|
|
244
|
+
function isPackPath(arg: string): boolean {
|
|
245
|
+
return /\.(tgz|tar\.gz)$/i.test(arg);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `dekc ref <file>.tgz`: pin the pack's deck as `file/<deck>` to the digest of its files, keep
|
|
250
|
+
* the pack as packs/<deck>.tgz for git, and unpack the snapshot. Everything is read and checked
|
|
251
|
+
* before the first write.
|
|
252
|
+
*/
|
|
253
|
+
async function addPack(cwd: string, arg: string): Promise<RefAddResult> {
|
|
254
|
+
const project = requireProject(cwd);
|
|
255
|
+
const given = resolve(cwd, arg);
|
|
256
|
+
if (!existsSync(given)) {
|
|
257
|
+
throw new DekError(`no such file: ${arg}`, {
|
|
258
|
+
path: given,
|
|
259
|
+
hint: "pass the path of the .tgz `dekc pack` wrote",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const read = await readPackFile(given, undefined);
|
|
263
|
+
const { source, bytes } = read;
|
|
264
|
+
refuseSharedFolders(project, source);
|
|
265
|
+
const rev = await snapshotDigest(read);
|
|
266
|
+
const { pinned: from, dir, fetched } = refState(project, source.name);
|
|
267
|
+
const stored = join(project.root, packFile(source.deck));
|
|
268
|
+
// Before the first write: the copy below is written by name.
|
|
269
|
+
refuseLinkUnderPacks(project.root, source.deck);
|
|
270
|
+
|
|
271
|
+
let changed = false;
|
|
272
|
+
if (!(fetched && from === rev)) {
|
|
273
|
+
await writeSnapshot(project.root, source.name, rev, read);
|
|
274
|
+
changed = true;
|
|
275
|
+
}
|
|
276
|
+
if (!(await isPackOf(stored, source, rev, project.root))) {
|
|
277
|
+
writeInside(stored, bytes, project.root);
|
|
278
|
+
changed = true;
|
|
279
|
+
}
|
|
280
|
+
if (from !== rev) {
|
|
281
|
+
const toml = readSourceIfExists(project.configPath, project.root) ?? "";
|
|
282
|
+
writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
|
|
283
|
+
changed = true;
|
|
284
|
+
}
|
|
285
|
+
ignoreRefs(project.root);
|
|
286
|
+
writeAgentsMd(project.root);
|
|
287
|
+
|
|
288
|
+
const license = refLicense(dir);
|
|
289
|
+
return {
|
|
290
|
+
action: "add",
|
|
291
|
+
name: source.name,
|
|
292
|
+
rev,
|
|
293
|
+
...(from !== undefined && from !== rev ? { from } : {}),
|
|
294
|
+
changed,
|
|
295
|
+
dir,
|
|
296
|
+
license,
|
|
297
|
+
pack: stored,
|
|
298
|
+
warnings:
|
|
299
|
+
license === null
|
|
300
|
+
? [
|
|
301
|
+
"the pack has no license; read it as a model, but ask its author before reusing its text or assets",
|
|
302
|
+
]
|
|
303
|
+
: [],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** True when the pack at `path` holds the files `rev` pins. */
|
|
308
|
+
async function isPackOf(
|
|
309
|
+
path: string,
|
|
310
|
+
source: FileRefSource,
|
|
311
|
+
rev: string,
|
|
312
|
+
root: string,
|
|
313
|
+
): Promise<boolean> {
|
|
314
|
+
if (!existsSync(path)) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
return (await snapshotDigest(await readPackFile(path, source, root))) === rev;
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
106
324
|
export function listRefs(cwd: string): RefListResult {
|
|
107
325
|
const project = requireProject(cwd);
|
|
108
326
|
const refs = Object.keys(loadConfig(project.configPath).refs ?? {}).map((name) => {
|
|
@@ -123,8 +341,13 @@ export function removeRef(cwd: string, arg: string): RefRmResult {
|
|
|
123
341
|
hint: "run `dekc ref` to list refs",
|
|
124
342
|
});
|
|
125
343
|
}
|
|
344
|
+
// Before anything changes: the deletes below must not reach what a link there points to.
|
|
345
|
+
refuseLinkUnderRefs(project.root, dir);
|
|
346
|
+
if (source.kind === "file") {
|
|
347
|
+
refuseLinkUnderPacks(project.root, source.deck);
|
|
348
|
+
}
|
|
126
349
|
if (pinned !== undefined) {
|
|
127
|
-
const toml =
|
|
350
|
+
const toml = readSourceIfExists(project.configPath, project.root) ?? "";
|
|
128
351
|
writeInside(
|
|
129
352
|
project.configPath,
|
|
130
353
|
setTableKey(toml, "refs", source.name, undefined),
|
|
@@ -133,6 +356,17 @@ export function removeRef(cwd: string, arg: string): RefRmResult {
|
|
|
133
356
|
}
|
|
134
357
|
removeInside(dir, project.root, { recursive: true });
|
|
135
358
|
removeEmptyParents(dirname(dir), project.root);
|
|
359
|
+
if (source.kind === "file") {
|
|
360
|
+
const packs = join(project.root, "packs");
|
|
361
|
+
removeInside(join(project.root, packFile(source.deck)), project.root);
|
|
362
|
+
if (
|
|
363
|
+
existsSync(packs) &&
|
|
364
|
+
!lstatSync(packs).isSymbolicLink() &&
|
|
365
|
+
readdirSync(packs).length === 0
|
|
366
|
+
) {
|
|
367
|
+
rmdirSync(packs);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
136
370
|
writeAgentsMd(project.root);
|
|
137
371
|
return { action: "rm", name: source.name };
|
|
138
372
|
}
|
package/src/cli/result.ts
CHANGED
|
@@ -13,15 +13,18 @@ export function skippedChecks(skipped: SkippedCheck[]): { skipped?: SkippedCheck
|
|
|
13
13
|
return skipped.length > 0 ? { skipped } : {};
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** What `--visual` measures in a browser, as a hint or a reason names it. */
|
|
17
|
+
export const RENDERED_RULES = "overflow, contrast, line breaks, and alignment";
|
|
18
|
+
|
|
16
19
|
/**
|
|
17
|
-
* The
|
|
20
|
+
* The rules a browser measures, when a run leaves them out: why, and `rerun`, the command that
|
|
18
21
|
* measures them. Without Playwright, installing it comes first, whatever the reason was.
|
|
19
22
|
*/
|
|
20
23
|
export function visualSkipped(
|
|
21
24
|
rerun: string,
|
|
22
25
|
options: { reason: string; runner?: PlaywrightRunner },
|
|
23
26
|
): SkippedCheck {
|
|
24
|
-
const measure = `run \`${rerun}\` to measure
|
|
27
|
+
const measure = `run \`${rerun}\` to measure ${RENDERED_RULES}`;
|
|
25
28
|
return playwrightReady(options.runner)
|
|
26
29
|
? { check: "visual", reason: options.reason, hint: measure }
|
|
27
30
|
: {
|