@hajimism/dek 0.3.2 → 0.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  Release notes for `@hajimism/dek`. From 0.1.0 on, release-please writes this file from the Conventional Commits on `main`.
4
4
 
5
+ ## [0.3.3](https://github.com/hajimism/dek/compare/v0.3.2...v0.3.3) (2026-10-21)
6
+
7
+
8
+ ### Features
9
+
10
+ * **new:** start a deck on a ref's theme with `--theme-from <ref>` ([6d0ffc9](https://github.com/hajimism/dek/commit/6d0ffc9c45a54092375ca73e305294e71e005655))
11
+ * **new:** start a deck on a ref's theme with `--theme-from <ref>` ([54be6db](https://github.com/hajimism/dek/commit/54be6db71b2521fda1bec3ddb967a4a7545bc36c))
12
+ * **pack:** hand a deck over under CC0 1.0 unless dek.toml says otherwise ([b7e2565](https://github.com/hajimism/dek/commit/b7e2565fd83a8d82c8d8416f6ae3551ba5040427))
13
+ * **pack:** hand a deck over under CC0 1.0 unless dek.toml says otherwise ([46cd7b9](https://github.com/hajimism/dek/commit/46cd7b9424d9e76570c2e017f2345b41f85506b3))
14
+ * **ref:** hand a deck over with `dekc pack` and read it with `dekc ref <file>.tgz` ([9e77bbc](https://github.com/hajimism/dek/commit/9e77bbc7bdf9f2e232c115903c73bf8fa8f078fe))
15
+ * **ref:** hand a deck over with `dekc pack` and read it with `dekc ref <file>.tgz` ([a0c67bc](https://github.com/hajimism/dek/commit/a0c67bc8258a2132dd77eadb2b87c838e6e9f866))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **dev:** leave the Enter and Escape that end a conversion to the input method ([f148c54](https://github.com/hajimism/dek/commit/f148c54377460b4df11e8fedd059554f00c06889))
21
+ * **dev:** leave the Enter and Escape that end a conversion to the input method ([e2b3940](https://github.com/hajimism/dek/commit/e2b3940cf48168b5a7c2b95250df982566222b60))
22
+
23
+
24
+ ### Documentation
25
+
26
+ * **adr:** hand a deck over as a pack and read it as a ref ([4b3ffc6](https://github.com/hajimism/dek/commit/4b3ffc6e653757f3976a581acff1a2bf76862748))
27
+ * **adr:** start a deck from a ref's theme ([f836941](https://github.com/hajimism/dek/commit/f836941cb2faf9b33faaab63abd16f66878e4767))
28
+ * describe packs in the structure guide, the reference, and ADR 15 ([020d4a0](https://github.com/hajimism/dek/commit/020d4a0254138c55e0a78c41e2e28f8a839bf8d1))
29
+ * record the pack's license in ADR 16, the guide, and the reference ([a0b4ea7](https://github.com/hajimism/dek/commit/a0b4ea74b6d896d102c1c0e2c06a040b75905962))
30
+ * say a deck can start on a ref's theme, in the guides and the reference ([49791ff](https://github.com/hajimism/dek/commit/49791ff46f77e5fd37e9d21f4061669de31af1f9))
31
+
5
32
  ## [0.3.2](https://github.com/hajimism/dek/compare/v0.3.1...v0.3.2) (2026-10-20)
6
33
 
7
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hajimism/dek",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "A build system for talks.",
5
5
  "license": "MIT",
6
6
  "author": "Hajime Nakagawa (https://github.com/hajimism)",
@@ -1,4 +1,5 @@
1
1
  import { hasErrors } from "../core/diagnostic.ts";
2
+ import { isRefName } from "../core/ref-name.ts";
2
3
  import { mergeSarif, toSarif } from "../core/sarif.ts";
3
4
  import type { CheckCliResult } from "./check.ts";
4
5
  import type { ResultFields } from "./contract.ts";
@@ -21,6 +22,7 @@ import {
21
22
  formatMv,
22
23
  formatNav,
23
24
  formatNew,
25
+ formatPack,
24
26
  formatPdf,
25
27
  formatRef,
26
28
  formatReport,
@@ -266,14 +268,22 @@ export const COMMANDS = {
266
268
  new: result({
267
269
  args: ["name"],
268
270
  flags: ["theme-from"],
269
- usage: ["dekc new <name> [--theme-from DECK]"],
270
- summary: "Add a deck with the project's theme, or another deck's, and its skeleton slides.",
271
+ usage: ["dekc new <name> [--theme-from DECK|REF]"],
272
+ summary:
273
+ "Add a deck with the project's theme, another deck's, or a ref's, and its skeleton slides.",
271
274
  group: "Project",
272
- overview: [["dekc new <name> [--theme-from DECK]", "add a deck"]],
273
- agent: ["dekc new <name> [--theme-from DECK]"],
275
+ overview: [["dekc new <name> [--theme-from DECK|REF]", "add a deck"]],
276
+ agent: [
277
+ "dekc new <name> [--theme-from DECK|REF] a ref's theme to build on its layouts; copied, never linked",
278
+ ],
274
279
  run: async ({ cwd, args, flags }) => {
280
+ const themeFrom = flags["theme-from"];
275
281
  const { newCommand } = await import("./new.ts");
276
- return newCommand({ cwd, name: args.name, themeFrom: flags["theme-from"] });
282
+ if (themeFrom !== undefined && isRefName(themeFrom)) {
283
+ const { restoreRef } = await import("./ref.ts");
284
+ await restoreRef(cwd, themeFrom);
285
+ }
286
+ return newCommand({ cwd, name: args.name, themeFrom });
277
287
  },
278
288
  output: {
279
289
  text: formatNew,
@@ -313,7 +323,7 @@ export const COMMANDS = {
313
323
  ref: result({
314
324
  args: ["source?"],
315
325
  flags: [],
316
- usage: ["dekc ref <owner/repo/deck>[@rev]", "dekc ref"],
326
+ usage: ["dekc ref <owner/repo/deck>[@rev]", "dekc ref <file>.tgz", "dekc ref"],
317
327
  summary:
318
328
  "Pin another project's deck to read as a model, list the pins, or drop one.\nls, show, theme, and shot read a ref as they read a deck.",
319
329
  group: "Refs",
@@ -322,11 +332,13 @@ export const COMMANDS = {
322
332
  "dekc ref owner/repo/deck[@rev]",
323
333
  "pin and fetch one (a GitHub link works too); again moves the pin",
324
334
  ],
335
+ ["dekc ref <file>.tgz", "pin a pack someone sent as file/<deck>; commit packs/"],
325
336
  ["dekc ref", "list pinned refs"],
326
337
  ["dekc ref rm <ref>", "drop one"],
327
338
  ],
328
339
  agent: [
329
340
  "dekc ref [owner/repo/deck[@rev] | github-link] pin + fetch; no args lists; dekc ref rm <ref>",
341
+ "dekc ref <file>.tgz pin a pack as file/<deck>; commit packs/<deck>.tgz, which a clone unpacks",
330
342
  ],
331
343
  run: async ({ cwd, args }) => {
332
344
  const { addRef, listRefs } = await import("./ref.ts");
@@ -343,7 +355,31 @@ export const COMMANDS = {
343
355
  },
344
356
  },
345
357
  },
346
- output: { text: formatRef },
358
+ output: {
359
+ text: formatRef,
360
+ paths: (data, display) =>
361
+ data.action === "add" && data.pack !== undefined
362
+ ? { ...data, pack: display(data.pack) }
363
+ : data,
364
+ },
365
+ }),
366
+ pack: result({
367
+ scope: "decks",
368
+ args: [],
369
+ flags: ["root-dist"],
370
+ usage: ["dekc pack [deck] [--root-dist]"],
371
+ summary:
372
+ "Write dist/<deck>.tgz: the deck as a file to hand to someone who reads it as a ref with\n`dekc ref <file>.tgz`, without access to where it lives.",
373
+ group: "Refs",
374
+ overview: [["dekc pack [--root-dist]", "write the deck as a file for `dekc ref` elsewhere"]],
375
+ agent: [
376
+ "dekc pack [--root-dist] dist/<deck>.tgz, for another project's `dekc ref <file>.tgz`",
377
+ ],
378
+ run: async ({ target, flags }) => {
379
+ const { packCommand } = await import("./pack.ts");
380
+ return packCommand(target, { rootDist: flags["root-dist"] });
381
+ },
382
+ output: { text: formatPack },
347
383
  }),
348
384
  show: result({
349
385
  scope: "deck",
@@ -111,6 +111,7 @@ export const RESULT_FIELDS = {
111
111
  created: paths,
112
112
  updated: paths,
113
113
  next: z.array(z.string()),
114
+ warnings: z.array(z.string()).describe("Set when the theme came from a ref with no license."),
114
115
  }),
115
116
  ls: z.union([
116
117
  z.strictObject({
@@ -159,6 +160,10 @@ export const RESULT_FIELDS = {
159
160
  changed: z.boolean(),
160
161
  dir: z.string(),
161
162
  license: z.string().nullable(),
163
+ pack: z
164
+ .string()
165
+ .optional()
166
+ .describe("A pack's ref: the copy in packs/ to commit, which a clone unpacks it from."),
162
167
  warnings: z.array(z.string()),
163
168
  }),
164
169
  z.strictObject({
@@ -413,6 +418,10 @@ export const RESULT_FIELDS = {
413
418
  }),
414
419
  pdf: z.strictObject({ outs: paths }),
415
420
  pptx: z.strictObject({ outs: paths }),
421
+ pack: z.strictObject({
422
+ outs: paths,
423
+ warnings: z.array(z.string()).describe("Once per project: the packs carry no license."),
424
+ }),
416
425
  } satisfies Record<ResultCommand, FieldsSchema>;
417
426
 
418
427
  type FieldsSchema = z.ZodObject | z.ZodUnion<readonly z.ZodObject[]>;
package/src/cli/files.ts CHANGED
@@ -14,7 +14,14 @@ export function defaultTheme(): string {
14
14
  }
15
15
 
16
16
  export function defaultToml(): string {
17
- return "# dek project\n";
17
+ return `# dek project
18
+
19
+ [pack]
20
+ # The license \`dekc pack\` hands a deck over under. CC0-1.0 lets the reader reuse it
21
+ # freely, with no credit owed, as a template is meant to be used; CC-BY-4.0 asks for
22
+ # credit. Delete the line to hand decks over with no license.
23
+ license = "CC0-1.0"
24
+ `;
18
25
  }
19
26
 
20
27
  /**
package/src/cli/new.ts CHANGED
@@ -4,9 +4,10 @@ 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";
7
8
  import { syncDeck } from "../core/sync.ts";
8
9
  import { applyPlan, deckPlan, nextSteps } from "./files.ts";
9
- import { requireDeckFromCwd, requireProject } from "./scope.ts";
10
+ import { type RefInfo, requireDeckFromCwd, requireProject, resolveTarget } from "./scope.ts";
10
11
 
11
12
  export type NewResult = {
12
13
  name: string;
@@ -16,6 +17,8 @@ export type NewResult = {
16
17
  updated: string[];
17
18
  /** The commands to run next, from the directory new ran in. */
18
19
  next: string[];
20
+ /** Set when the theme came from a ref with no license. */
21
+ warnings: string[];
19
22
  };
20
23
 
21
24
  export function newCommand(options: { cwd: string; name: string; themeFrom?: string }): NewResult {
@@ -35,19 +38,29 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
35
38
  });
36
39
  }
37
40
 
38
- const themeSource = options.themeFrom
39
- ? deckPaths(requireDeckFromCwd(options.cwd, options.themeFrom).deck.dir).theme
40
- : join(project.root, "theme.css");
41
+ // A ref is read where it is, never written: only the copy below is the new deck's.
42
+ const fromRef =
43
+ options.themeFrom !== undefined && isRefName(options.themeFrom)
44
+ ? resolveTarget(options.cwd, "deck", { refs: true, deck: options.themeFrom })
45
+ : undefined;
46
+ const ref = fromRef?.ref;
47
+ const themeSource = fromRef
48
+ ? deckPaths(fromRef.deck.dir).theme
49
+ : options.themeFrom
50
+ ? deckPaths(requireDeckFromCwd(options.cwd, options.themeFrom).deck.dir).theme
51
+ : join(project.root, "theme.css");
41
52
  if (!existsSync(themeSource)) {
42
53
  throw new DekError("theme.css not found", {
43
54
  path: themeSource,
44
- hint: options.themeFrom
45
- ? "run `dekc ls` and pick a deck that has theme.css"
46
- : "add theme.css at the project root",
55
+ hint: ref
56
+ ? "run `dekc ref` to list the refs, and pick one whose deck has theme.css"
57
+ : options.themeFrom
58
+ ? "run `dekc ls` and pick a deck that has theme.css"
59
+ : "add theme.css at the project root",
47
60
  });
48
61
  }
49
62
 
50
- const theme = readFileSync(themeSource, "utf8");
63
+ const theme = `${ref ? provenance(ref) : ""}${readFileSync(themeSource, "utf8")}`;
51
64
  const { created } = applyPlan(
52
65
  deckPlan(project.root, name, theme, loadConfig(project.configPath)),
53
66
  );
@@ -61,5 +74,16 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
61
74
  created,
62
75
  updated: synced.dekFiles.updated,
63
76
  next: nextSteps(options.cwd, project.root, dir),
77
+ warnings:
78
+ ref && ref.license === null
79
+ ? [
80
+ `${ref.name} has no license; ask its author before you present or share a deck built on its theme`,
81
+ ]
82
+ : [],
64
83
  };
65
84
  }
85
+
86
+ /** The first line of a theme copied from a ref: where it came from, for its reader and its credit. */
87
+ function provenance(ref: RefInfo): string {
88
+ return `/* Theme from the ref ${ref.name} at ${ref.rev.slice(0, 7)}. */\n`;
89
+ }
@@ -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/ref.ts CHANGED
@@ -1,20 +1,27 @@
1
1
  import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync } from "node:fs";
2
- import { dirname, join } from "node:path";
2
+ import { dirname, join, resolve } from "node:path";
3
3
  import { loadConfig } from "../core/config.ts";
4
4
  import { DekError } from "../core/error.ts";
5
5
  import { downloadTarball, resolveRev } from "../core/github.ts";
6
6
  import {
7
+ deckOfPack,
7
8
  installSnapshot,
8
9
  isPinnedRev,
10
+ packFile,
9
11
  parseRefSource,
10
- type RefSource,
12
+ pickSnapshot,
13
+ readPack,
11
14
  readTarball,
12
15
  refLicense,
13
16
  refState,
14
17
  refTitle,
18
+ type Snapshot,
19
+ snapshotDigest,
20
+ writeSnapshot,
15
21
  } from "../core/ref.ts";
22
+ import type { FileRefSource, GithubRefSource } from "../core/ref-name.ts";
16
23
  import { readTextIfExists } from "../core/resolve.ts";
17
- import { readSourceIfExists, removeInside, writeInside } from "../core/safe-fs.ts";
24
+ import { readSourceIfExists, removeInside, requireInside, writeInside } from "../core/safe-fs.ts";
18
25
  import { writeAgentsMd } from "../core/sync.ts";
19
26
  import { setTableKey } from "../core/toml-keys.ts";
20
27
  import { requireProject } from "./scope.ts";
@@ -29,6 +36,8 @@ export type RefAddResult = {
29
36
  changed: boolean;
30
37
  dir: string;
31
38
  license: string | null;
39
+ /** A pack's ref: its committed copy, which a clone unpacks the ref from. */
40
+ pack?: string;
32
41
  warnings: string[];
33
42
  };
34
43
 
@@ -53,17 +62,71 @@ export async function restoreRef(cwd: string, arg: string): Promise<void> {
53
62
  if (pinned === undefined || fetched) {
54
63
  return;
55
64
  }
65
+ if (source.kind === "file") {
66
+ await unpackSnapshot(project.root, source, pinned);
67
+ return;
68
+ }
56
69
  await fetchSnapshot(project.root, source, pinned);
57
70
  }
58
71
 
59
- async function fetchSnapshot(root: string, source: RefSource, sha: string) {
72
+ async function fetchSnapshot(root: string, source: GithubRefSource, sha: string) {
60
73
  const files = await readTarball(await downloadTarball(source.owner, source.repo, sha));
61
74
  return installSnapshot(root, source, sha, files);
62
75
  }
63
76
 
77
+ /** A pack's snapshot, unpacked again from its committed copy, which must be the pinned one. */
78
+ async function unpackSnapshot(root: string, source: FileRefSource, pin: string): Promise<void> {
79
+ const rel = packFile(source.deck);
80
+ const path = join(root, rel);
81
+ if (!existsSync(path)) {
82
+ throw new DekError(`${rel} is missing`, {
83
+ path,
84
+ 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`,
85
+ });
86
+ }
87
+ const snapshot = await readPackFile(path, source, root);
88
+ if ((await snapshotDigest(snapshot)) !== pin) {
89
+ throw new DekError(`${rel} is not the pack ${source.name} is pinned to`, {
90
+ path,
91
+ hint: `run \`dekc ref ${rel}\` to pin the pack that is there, or put back the one dek.toml pins`,
92
+ });
93
+ }
94
+ await writeSnapshot(root, source.name, pin, snapshot);
95
+ }
96
+
97
+ /** What a snapshot keeps of the pack at `path`; an error says which file it was about. */
98
+ async function readPackFile(
99
+ path: string,
100
+ source: FileRefSource | undefined,
101
+ root?: string,
102
+ ): Promise<Snapshot & { source: FileRefSource; bytes: Uint8Array }> {
103
+ try {
104
+ const bytes = readFileSync(root === undefined ? path : requireInside(path, root));
105
+ const files = await readPack(bytes);
106
+ const found = source ?? parseRefSource(`file/${deckOfPack(files)}`);
107
+ if (found.kind !== "file") {
108
+ throw new DekError("the pack's deck cannot be named as a ref");
109
+ }
110
+ return { ...pickSnapshot(files, found), source: found, bytes };
111
+ } catch (error) {
112
+ if (error instanceof DekError && error.path === undefined) {
113
+ throw new DekError(error.message, { path, ...(error.hint ? { hint: error.hint } : {}) });
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+
64
119
  /** `dekc ref <source>`: pin and fetch, or move the pin to the latest or to the rev after `@`. */
65
120
  export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
121
+ if (isPackPath(arg)) {
122
+ return addPack(cwd, arg);
123
+ }
66
124
  const source = parseRefSource(arg);
125
+ if (source.kind === "file") {
126
+ throw new DekError(`"${arg}" names a pack's ref`, {
127
+ hint: "add a pack by its file, `dekc ref <file>.tgz`; `dekc ref` lists the refs already added",
128
+ });
129
+ }
67
130
  const project = requireProject(cwd);
68
131
  const { pinned: from, dir, fetched } = refState(project, source.name);
69
132
  const rev =
@@ -103,6 +166,84 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
103
166
  };
104
167
  }
105
168
 
169
+ /** An argument that ends in .tgz or .tar.gz is a pack's path, never a ref name. */
170
+ function isPackPath(arg: string): boolean {
171
+ return /\.(tgz|tar\.gz)$/i.test(arg);
172
+ }
173
+
174
+ /**
175
+ * `dekc ref <file>.tgz`: pin the pack's deck as `file/<deck>` to the digest of its files, keep
176
+ * the pack as packs/<deck>.tgz for git, and unpack the snapshot. Everything is read and checked
177
+ * before the first write.
178
+ */
179
+ async function addPack(cwd: string, arg: string): Promise<RefAddResult> {
180
+ const project = requireProject(cwd);
181
+ const given = resolve(cwd, arg);
182
+ if (!existsSync(given)) {
183
+ throw new DekError(`no such file: ${arg}`, {
184
+ path: given,
185
+ hint: "pass the path of the .tgz `dekc pack` wrote",
186
+ });
187
+ }
188
+ const read = await readPackFile(given, undefined);
189
+ const { source, bytes } = read;
190
+ const rev = await snapshotDigest(read);
191
+ const { pinned: from, dir, fetched } = refState(project, source.name);
192
+ const stored = join(project.root, packFile(source.deck));
193
+
194
+ let changed = false;
195
+ if (!(fetched && from === rev)) {
196
+ await writeSnapshot(project.root, source.name, rev, read);
197
+ changed = true;
198
+ }
199
+ if (!(await isPackOf(stored, source, rev, project.root))) {
200
+ writeInside(stored, bytes, project.root);
201
+ changed = true;
202
+ }
203
+ if (from !== rev) {
204
+ const toml = readTextIfExists(project.configPath) ?? "";
205
+ writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
206
+ changed = true;
207
+ }
208
+ ignoreRefs(project.root);
209
+ writeAgentsMd(project.root);
210
+
211
+ const license = refLicense(dir);
212
+ return {
213
+ action: "add",
214
+ name: source.name,
215
+ rev,
216
+ ...(from !== undefined && from !== rev ? { from } : {}),
217
+ changed,
218
+ dir,
219
+ license,
220
+ pack: stored,
221
+ warnings:
222
+ license === null
223
+ ? [
224
+ "the pack has no license; read it as a model, but ask its author before reusing its text or assets",
225
+ ]
226
+ : [],
227
+ };
228
+ }
229
+
230
+ /** True when the pack at `path` holds the files `rev` pins. */
231
+ async function isPackOf(
232
+ path: string,
233
+ source: FileRefSource,
234
+ rev: string,
235
+ root: string,
236
+ ): Promise<boolean> {
237
+ if (!existsSync(path)) {
238
+ return false;
239
+ }
240
+ try {
241
+ return (await snapshotDigest(await readPackFile(path, source, root))) === rev;
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
106
247
  export function listRefs(cwd: string): RefListResult {
107
248
  const project = requireProject(cwd);
108
249
  const refs = Object.keys(loadConfig(project.configPath).refs ?? {}).map((name) => {
@@ -133,6 +274,17 @@ export function removeRef(cwd: string, arg: string): RefRmResult {
133
274
  }
134
275
  removeInside(dir, project.root, { recursive: true });
135
276
  removeEmptyParents(dirname(dir), project.root);
277
+ if (source.kind === "file") {
278
+ const packs = join(project.root, "packs");
279
+ removeInside(join(project.root, packFile(source.deck)), project.root);
280
+ if (
281
+ existsSync(packs) &&
282
+ !lstatSync(packs).isSymbolicLink() &&
283
+ readdirSync(packs).length === 0
284
+ ) {
285
+ rmdirSync(packs);
286
+ }
287
+ }
136
288
  writeAgentsMd(project.root);
137
289
  return { action: "rm", name: source.name };
138
290
  }
package/src/cli/scope.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { DekError } from "../core/error.ts";
4
4
  import {
5
5
  isRefName,
6
+ packFile,
6
7
  parseRefSource,
7
8
  REF_MARKER,
8
9
  readRefMeta,
@@ -139,7 +140,7 @@ export function resolveScope(cwd: string, options: { deck?: string } = {}): Scop
139
140
  }
140
141
  if (isRefName(name)) {
141
142
  throw new DekError(`"${name}" is a ref; refs are read-only`, {
142
- hint: `read it with \`dekc show ${name} <slug>\`, then copy what you need into your own deck and rewrite it in your theme`,
143
+ hint: `start a deck on its theme with \`dekc new <name> --theme-from ${name}\`, or read it with \`dekc show ${name} <slug>\` and write what you need in your own deck's theme`,
143
144
  });
144
145
  }
145
146
  const deck = project.decks.find((entry) => entry.name === name);
@@ -273,10 +274,18 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
273
274
  if (pinned === undefined) {
274
275
  throw new DekError(`ref "${source.name}" is not added`, {
275
276
  path: project.configPath,
276
- hint: `run \`dekc ref ${arg}\``,
277
+ hint:
278
+ source.kind === "file"
279
+ ? "add it from the pack you were sent: `dekc ref <file>.tgz`"
280
+ : `run \`dekc ref ${arg}\``,
277
281
  });
278
282
  }
279
- if (source.rev !== undefined && source.rev !== pinned) {
283
+ // What adds the ref again: a pack's ref from its file, any other from its name.
284
+ const again =
285
+ source.kind === "file"
286
+ ? `dekc ref ${packFile(source.deck)}`
287
+ : `dekc ref ${source.name}@${pinned}`;
288
+ if (source.kind === "github" && source.rev !== undefined && source.rev !== pinned) {
280
289
  throw new DekError(`"${arg}" is not the pinned version, ${pinned.slice(0, 7)}`, {
281
290
  path: project.configPath,
282
291
  hint: `run \`dekc ref ${arg}\` to pin that version, or drop @${source.rev}`,
@@ -285,7 +294,7 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
285
294
  if (!fetched) {
286
295
  throw new DekError(`ref "${source.name}" is not fetched`, {
287
296
  path: dir,
288
- hint: `run \`dekc ref ${source.name}@${pinned}\``,
297
+ hint: `run \`${again}\``,
289
298
  });
290
299
  }
291
300
  const snapshot = resolveProject(dir);
@@ -297,7 +306,7 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
297
306
  }
298
307
  throw new DekError(`ref "${source.name}" has no deck "${source.deck}"`, {
299
308
  path: dir,
300
- hint: `run \`dekc ref ${source.name}@${pinned}\` to fetch it again`,
309
+ hint: `run \`${again}\` to fetch it again`,
301
310
  });
302
311
  }
303
312
  return {
package/src/cli/text.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // How each command's result reads as text; commands.ts says which command prints which.
2
2
  // The pieces the dev server's log shares, such as diagnostics and tables, are in format.ts.
3
3
  import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
4
+ import { packFile, parseRefSource } from "../core/ref-name.ts";
4
5
  import { formatClock } from "../core/timing.ts";
5
6
  import type { AnnotationsCliResult } from "./annotations.ts";
6
7
  import type { BuildCliResult } from "./build.ts";
@@ -13,6 +14,7 @@ import type { LsDeckResult, LsListResult } from "./ls.ts";
13
14
  import type { MarksCliResult } from "./marks.ts";
14
15
  import type { MvResult } from "./mv.ts";
15
16
  import type { NewResult } from "./new.ts";
17
+ import type { PackCliResult } from "./pack.ts";
16
18
  import type { PdfCliResult } from "./pdf.ts";
17
19
  import type { RefCliResult } from "./ref.ts";
18
20
  import type { ShotCliResult } from "./shot.ts";
@@ -130,7 +132,8 @@ export function formatInit(data: InitResult): string {
130
132
  /** The deck, and any of dek's own files it brought up to date: the rest is the deck's, and new. */
131
133
  export function formatNew(data: NewResult): string {
132
134
  const updated = data.updated.map((path) => ` ${path} (updated)`);
133
- return withNext([`created deck ${data.name}`, ...updated].join("\n"), data.next);
135
+ const warnings = data.warnings.map((warning) => `warning: ${warning}`);
136
+ return withNext([`created deck ${data.name}`, ...updated, ...warnings].join("\n"), data.next);
134
137
  }
135
138
 
136
139
  export function formatMv(data: MvResult): string {
@@ -227,6 +230,10 @@ export function formatPdf(data: PdfCliResult): string {
227
230
  return data.outs.map((path) => `wrote ${path}`).join("\n");
228
231
  }
229
232
 
233
+ export function formatPack(data: PackCliResult): string {
234
+ return [formatPdf(data), ...data.warnings.map((warning) => `warning: ${warning}`)].join("\n");
235
+ }
236
+
230
237
  export function formatLs(data: LsListResult | LsDeckResult): string {
231
238
  switch (data.kind) {
232
239
  case "list": {
@@ -409,6 +416,14 @@ export function formatShow(data: ShowResult): string {
409
416
  .join("\n\n");
410
417
  }
411
418
 
419
+ /** What a read does for a ref whose snapshot is not there: fetch it, or unpack its pack. */
420
+ function notFetched(name: string): string {
421
+ const source = parseRefSource(name);
422
+ return source.kind === "file"
423
+ ? `(not unpacked; a read unpacks ${packFile(source.deck)})`
424
+ : "(not fetched; a read fetches it)";
425
+ }
426
+
412
427
  export function formatRef(data: RefCliResult): string {
413
428
  const short = (rev: string) => rev.slice(0, 7);
414
429
  switch (data.action) {
@@ -421,6 +436,7 @@ export function formatRef(data: RefCliResult): string {
421
436
  return [
422
437
  head,
423
438
  ...data.warnings.map((warning) => `warning: ${warning}`),
439
+ ...(data.pack ? [`commit ${data.pack}: a clone unpacks the ref from it`] : []),
424
440
  `read it with \`dekc ls ${data.name}\` and \`dekc show ${data.name} <slug>\``,
425
441
  ].join("\n");
426
442
  }
@@ -433,7 +449,7 @@ export function formatRef(data: RefCliResult): string {
433
449
  data.refs.map((ref) => [
434
450
  ref.name,
435
451
  short(ref.rev),
436
- ref.fetched ? (ref.title ?? "") : "(not fetched; a read fetches it)",
452
+ ref.fetched ? (ref.title ?? "") : notFetched(ref.name),
437
453
  ref.slides === undefined ? "" : String(ref.slides),
438
454
  ]),
439
455
  ["left", "left", "left", "right"],
@@ -1,4 +1,4 @@
1
- import { DekToml, Voice } from "./config.ts";
1
+ import { DekToml, Pack, Voice } from "./config.ts";
2
2
  import { splitLines } from "./lines.ts";
3
3
  import { readFrontmatterYaml } from "./parse.ts";
4
4
  import { Frontmatter } from "./schema.ts";
@@ -10,12 +10,16 @@ import { unrecognizedKeys } from "./zod.ts";
10
10
  export type UnknownKey = { key: string; line: number; suggestion?: string };
11
11
 
12
12
  /** dek.toml's schema, but a key it does not read is an issue; `refs` still takes any key. */
13
- const StrictDekToml = DekToml.extend({ voice: Voice.strict().optional() }).strict();
13
+ const StrictDekToml = DekToml.extend({
14
+ voice: Voice.strict().optional(),
15
+ pack: Pack.strict().optional(),
16
+ }).strict();
14
17
 
15
18
  /** Every key dek.toml may hold, as dotted paths. */
16
19
  const DEK_TOML_KEYS: readonly string[] = [
17
20
  ...Object.keys(DekToml.shape),
18
21
  ...Object.keys(Voice.shape).map((key) => `voice.${key}`),
22
+ ...Object.keys(Pack.shape).map((key) => `pack.${key}`),
19
23
  ];
20
24
 
21
25
  export const FRONTMATTER_KEYS: readonly string[] = Object.keys(Frontmatter.shape);