canon-ds 0.1.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/LICENSE +21 -0
- package/README.md +153 -0
- package/THIRD_PARTY_NOTICES.md +55 -0
- package/bin/canon.js +6 -0
- package/lib/build-manifest.mjs +54 -0
- package/lib/build.mjs +69 -0
- package/lib/cli.mjs +273 -0
- package/lib/color.js +160 -0
- package/lib/components/_shared.mjs +93 -0
- package/lib/components/accordion.mjs +136 -0
- package/lib/components/account-card.mjs +166 -0
- package/lib/components/activity-feed.mjs +176 -0
- package/lib/components/activity-gauge.mjs +98 -0
- package/lib/components/agent-presence.mjs +156 -0
- package/lib/components/alert.mjs +154 -0
- package/lib/components/app-store-button.mjs +108 -0
- package/lib/components/avatar-group.mjs +128 -0
- package/lib/components/avatar.mjs +225 -0
- package/lib/components/badge-group.mjs +55 -0
- package/lib/components/badge.mjs +174 -0
- package/lib/components/banner.mjs +116 -0
- package/lib/components/breadcrumb.mjs +170 -0
- package/lib/components/button-group.mjs +114 -0
- package/lib/components/button.mjs +197 -0
- package/lib/components/card-header.mjs +115 -0
- package/lib/components/card.mjs +106 -0
- package/lib/components/carousel.mjs +182 -0
- package/lib/components/chart-frame.mjs +101 -0
- package/lib/components/checkbox.mjs +163 -0
- package/lib/components/close-button.mjs +49 -0
- package/lib/components/code.mjs +123 -0
- package/lib/components/color-picker.mjs +122 -0
- package/lib/components/combobox.mjs +210 -0
- package/lib/components/command-palette.mjs +226 -0
- package/lib/components/content-divider.mjs +104 -0
- package/lib/components/counter.mjs +86 -0
- package/lib/components/credit-card.mjs +126 -0
- package/lib/components/date-picker.mjs +202 -0
- package/lib/components/description-list.mjs +88 -0
- package/lib/components/dialog.mjs +140 -0
- package/lib/components/divider.mjs +115 -0
- package/lib/components/drawer.mjs +150 -0
- package/lib/components/empty-state.mjs +112 -0
- package/lib/components/featured-card.mjs +124 -0
- package/lib/components/featured-icon.mjs +67 -0
- package/lib/components/field.mjs +122 -0
- package/lib/components/file-dropzone.mjs +166 -0
- package/lib/components/filter-bar.mjs +179 -0
- package/lib/components/header-navigation.mjs +94 -0
- package/lib/components/icon-button.mjs +179 -0
- package/lib/components/index.mjs +52 -0
- package/lib/components/inline-cta.mjs +139 -0
- package/lib/components/input-group.mjs +164 -0
- package/lib/components/input.mjs +197 -0
- package/lib/components/kanban.mjs +179 -0
- package/lib/components/kbd.mjs +70 -0
- package/lib/components/kicker.mjs +79 -0
- package/lib/components/link.mjs +108 -0
- package/lib/components/list.mjs +117 -0
- package/lib/components/media-frame.mjs +119 -0
- package/lib/components/menu.mjs +190 -0
- package/lib/components/message.mjs +249 -0
- package/lib/components/metric-group.mjs +210 -0
- package/lib/components/mobile-header.mjs +173 -0
- package/lib/components/multi-select.mjs +218 -0
- package/lib/components/notification.mjs +175 -0
- package/lib/components/number-input.mjs +149 -0
- package/lib/components/page-header.mjs +68 -0
- package/lib/components/pagination.mjs +236 -0
- package/lib/components/pin-input.mjs +120 -0
- package/lib/components/popover.mjs +126 -0
- package/lib/components/progress-circle.mjs +115 -0
- package/lib/components/progress.mjs +93 -0
- package/lib/components/prose.mjs +96 -0
- package/lib/components/radio.mjs +160 -0
- package/lib/components/rating.mjs +93 -0
- package/lib/components/rich-text-editor.mjs +178 -0
- package/lib/components/section-header.mjs +62 -0
- package/lib/components/segmented-control.mjs +121 -0
- package/lib/components/select.mjs +163 -0
- package/lib/components/sidebar-nav.mjs +172 -0
- package/lib/components/sidebar.mjs +124 -0
- package/lib/components/skeleton.mjs +66 -0
- package/lib/components/slider.mjs +122 -0
- package/lib/components/social-button.mjs +119 -0
- package/lib/components/spinner.mjs +69 -0
- package/lib/components/stat.mjs +120 -0
- package/lib/components/stepper.mjs +228 -0
- package/lib/components/switch.mjs +159 -0
- package/lib/components/table.mjs +212 -0
- package/lib/components/tabs.mjs +207 -0
- package/lib/components/tag.mjs +196 -0
- package/lib/components/tags-input.mjs +146 -0
- package/lib/components/textarea.mjs +117 -0
- package/lib/components/timeline.mjs +135 -0
- package/lib/components/toast.mjs +115 -0
- package/lib/components/tooltip.mjs +86 -0
- package/lib/components/topbar.mjs +207 -0
- package/lib/components/tree-view.mjs +156 -0
- package/lib/components/video-player.mjs +189 -0
- package/lib/connect.mjs +110 -0
- package/lib/design-files.mjs +175 -0
- package/lib/distribution.mjs +109 -0
- package/lib/editor.js +940 -0
- package/lib/engine.js +339 -0
- package/lib/generators/agents.mjs +68 -0
- package/lib/generators/connection.md +58 -0
- package/lib/generators/css.mjs +2 -0
- package/lib/generators/designmd.mjs +324 -0
- package/lib/generators/documentation.css +82 -0
- package/lib/generators/documentation.html +81 -0
- package/lib/generators/documentation.js +96 -0
- package/lib/generators/documentation.mjs +35 -0
- package/lib/generators/dtcg.mjs +60 -0
- package/lib/generators/preview.css +279 -0
- package/lib/generators/preview.mjs +225 -0
- package/lib/generators/react.mjs +162 -0
- package/lib/generators/tailwind.mjs +114 -0
- package/lib/install.mjs +108 -0
- package/lib/lint.mjs +538 -0
- package/lib/mcp.mjs +268 -0
- package/lib/open.mjs +7 -0
- package/lib/patterns/_app.mjs +56 -0
- package/lib/patterns/app-shell.mjs +25 -0
- package/lib/patterns/audit-log.mjs +37 -0
- package/lib/patterns/auth-pages.mjs +44 -0
- package/lib/patterns/billing-page.mjs +39 -0
- package/lib/patterns/calendar-page.mjs +55 -0
- package/lib/patterns/dashboard-page.mjs +28 -0
- package/lib/patterns/files-page.mjs +30 -0
- package/lib/patterns/first-run.mjs +31 -0
- package/lib/patterns/form-layout.mjs +24 -0
- package/lib/patterns/inbox-page.mjs +58 -0
- package/lib/patterns/index.mjs +29 -0
- package/lib/patterns/list-detail-page.mjs +24 -0
- package/lib/patterns/mobile-app.mjs +46 -0
- package/lib/patterns/modal-flows.mjs +32 -0
- package/lib/patterns/onboarding-page.mjs +42 -0
- package/lib/patterns/permissions-page.mjs +38 -0
- package/lib/patterns/profile-page.mjs +32 -0
- package/lib/patterns/settings-page.mjs +24 -0
- package/lib/project.mjs +28 -0
- package/lib/serve.mjs +279 -0
- package/lib/system.mjs +210 -0
- package/lib/tokens/base.js +251 -0
- package/lib/tokens/canon-preset.mjs +67 -0
- package/lib/tokens/index.mjs +1 -0
- package/lib/tokens/legacy-canon.json +559 -0
- package/lib/tokens/presets.mjs +161 -0
- package/lib/tokens/resolve.mjs +1 -0
- package/lib/types.mjs +4 -0
- package/lib/version.mjs +2 -0
- package/package.json +54 -0
package/lib/connect.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { buildSystem } from "./build.mjs";
|
|
5
|
+
import { installFiles, loadSource, sourcePath, validateSnapshot } from "./design-files.mjs";
|
|
6
|
+
import { install } from "./install.mjs";
|
|
7
|
+
import { findProject } from "./project.mjs";
|
|
8
|
+
import { writeDesignDir } from "./system.mjs";
|
|
9
|
+
import { compareSpecs } from "./components/index.mjs";
|
|
10
|
+
import { comparePatterns } from "./patterns/index.mjs";
|
|
11
|
+
const MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
async function readSnapshot(source) {
|
|
13
|
+
if (!/^https?:\/\//i.test(source)) {
|
|
14
|
+
if (statSync(source).size > MAX_SNAPSHOT_BYTES)
|
|
15
|
+
throw new Error('Design snapshot exceeds 8 MiB');
|
|
16
|
+
return JSON.parse(readFileSync(source, 'utf8'));
|
|
17
|
+
}
|
|
18
|
+
const base = new URL(source);
|
|
19
|
+
base.search = '';
|
|
20
|
+
base.hash = '';
|
|
21
|
+
// Public catalogs can live at /canon/ on a static host. Keep that directory
|
|
22
|
+
// for both the document URL and a site address copied from the browser.
|
|
23
|
+
if (!base.pathname.endsWith('/') && !base.pathname.split('/').at(-1).includes('.'))
|
|
24
|
+
base.pathname += '/';
|
|
25
|
+
const url = new URL('./api/system', base);
|
|
26
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: 'error' });
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
throw new Error(`Could not read the saved Studio design (HTTP ${response.status})`);
|
|
29
|
+
if (Number(response.headers.get('content-length') ?? 0) > MAX_SNAPSHOT_BYTES) {
|
|
30
|
+
await response.body?.cancel();
|
|
31
|
+
throw new Error('Design snapshot exceeds 8 MiB');
|
|
32
|
+
}
|
|
33
|
+
const reader = response.body?.getReader();
|
|
34
|
+
if (!reader)
|
|
35
|
+
throw new Error('Studio returned no design snapshot');
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let size = 0;
|
|
38
|
+
try {
|
|
39
|
+
while (true) {
|
|
40
|
+
const { done, value } = await reader.read();
|
|
41
|
+
if (done)
|
|
42
|
+
break;
|
|
43
|
+
size += value.byteLength;
|
|
44
|
+
if (size > MAX_SNAPSHOT_BYTES)
|
|
45
|
+
throw new Error('Design snapshot exceeds 8 MiB');
|
|
46
|
+
chunks.push(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await reader.cancel();
|
|
51
|
+
}
|
|
52
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
53
|
+
}
|
|
54
|
+
export async function connect(source, opts) {
|
|
55
|
+
mkdirSync(opts.root, { recursive: true });
|
|
56
|
+
const root = realpathSync(resolve(opts.root));
|
|
57
|
+
const project = findProject(root);
|
|
58
|
+
const design = opts.design ? resolve(root, opts.design) : project?.root === root ? project.design : join(root, 'design');
|
|
59
|
+
// New sources must be inside the selected project; explicitly configured existing
|
|
60
|
+
// sources may live elsewhere, but are still checked against their own boundary.
|
|
61
|
+
const existing = existsSync(join(design, 'system.json'));
|
|
62
|
+
const rel = relative(root, design);
|
|
63
|
+
const inside = !isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`);
|
|
64
|
+
if (!existing || inside)
|
|
65
|
+
sourcePath(root, design, 'directory');
|
|
66
|
+
else if (realpathSync(design) !== design)
|
|
67
|
+
throw new Error('Canon does not connect through linked design directories');
|
|
68
|
+
let reused = false;
|
|
69
|
+
if (existing) {
|
|
70
|
+
const system = validateSnapshot(loadSource(realpathSync(design)));
|
|
71
|
+
const stage = mkdtempSync(join(tmpdir(), 'canon-reconnect-'));
|
|
72
|
+
try {
|
|
73
|
+
const result = await buildSystem(system, stage);
|
|
74
|
+
installFiles(design, result.files.map(file => ({
|
|
75
|
+
path: join(design, system.meta.out, relative(result.outDir, file)), content: readFileSync(file),
|
|
76
|
+
})));
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
rmSync(stage, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
reused = true;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (existsSync(design) && readdirSync(design).length)
|
|
85
|
+
throw new Error(`Design destination is not empty: ${design}`);
|
|
86
|
+
if (!source)
|
|
87
|
+
throw new Error('Provide the Studio URL or a saved design snapshot to connect this project');
|
|
88
|
+
const system = validateSnapshot(await readSnapshot(source));
|
|
89
|
+
system.components.sort(compareSpecs);
|
|
90
|
+
system.patterns.sort(comparePatterns);
|
|
91
|
+
mkdirSync(dirname(design), { recursive: true });
|
|
92
|
+
const stage = mkdtempSync(join(dirname(design), '.canon-connect-'));
|
|
93
|
+
try {
|
|
94
|
+
writeDesignDir(system, stage);
|
|
95
|
+
await buildSystem(system, stage);
|
|
96
|
+
sourcePath(root, design, 'directory');
|
|
97
|
+
if (existsSync(design)) {
|
|
98
|
+
if (readdirSync(design).length)
|
|
99
|
+
throw new Error(`Design destination is not empty: ${design}`);
|
|
100
|
+
rmSync(design, { recursive: true });
|
|
101
|
+
}
|
|
102
|
+
renameSync(stage, design);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
rmSync(stage, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const { log } = install(loadSource(realpathSync(design)), design, { root, hooks: opts.hooks !== false });
|
|
109
|
+
return { design, reused, log };
|
|
110
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Shared design validation and rollback-capable local file writes.
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, lstatSync, realpathSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, relative, isAbsolute, sep, dirname } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { loadDesignDir, validateSystem } from "./system.mjs";
|
|
6
|
+
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
export class HttpError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(status, message) { super(message); this.status = status; }
|
|
10
|
+
}
|
|
11
|
+
export function requireValue(condition, message) {
|
|
12
|
+
if (!condition)
|
|
13
|
+
throw new HttpError(400, message);
|
|
14
|
+
}
|
|
15
|
+
export function record(value, name) {
|
|
16
|
+
requireValue(value !== null && typeof value === 'object' && !Array.isArray(value), `${name} must be an object`);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function strings(value, name) {
|
|
20
|
+
requireValue(Array.isArray(value) && value.every((item) => typeof item === 'string'), `${name} must be an array of strings`);
|
|
21
|
+
}
|
|
22
|
+
export function identifier(value, name) {
|
|
23
|
+
requireValue(typeof value === 'string' && value.length <= 128 && SLUG.test(value), `${name} must be a lowercase slug`);
|
|
24
|
+
}
|
|
25
|
+
export function validateMeta(value, designRoot, outputRoot) {
|
|
26
|
+
const meta = record(value, 'meta');
|
|
27
|
+
for (const key of ['name', 'version'])
|
|
28
|
+
requireValue(typeof meta[key] === 'string' && meta[key].trim(), `meta.${key} must be a nonempty string`);
|
|
29
|
+
identifier(meta.prefix, 'meta.prefix');
|
|
30
|
+
requireValue(meta.description === undefined || typeof meta.description === 'string', 'meta.description must be a string');
|
|
31
|
+
requireValue(meta.defaultTheme === 'light' || meta.defaultTheme === 'dark', 'meta.defaultTheme must be light or dark');
|
|
32
|
+
const direction = record(meta.direction, 'meta.direction');
|
|
33
|
+
requireValue(typeof direction.summary === 'string', 'meta.direction.summary must be a string');
|
|
34
|
+
strings(direction.principles, 'meta.direction.principles');
|
|
35
|
+
strings(direction.never, 'meta.direction.never');
|
|
36
|
+
const seeds = record(meta.seeds, 'meta.seeds');
|
|
37
|
+
for (const key of ['name', 'prefix', 'brand'])
|
|
38
|
+
requireValue(typeof seeds[key] === 'string', `meta.seeds.${key} must be a string`);
|
|
39
|
+
identifier(seeds.prefix, 'meta.seeds.prefix');
|
|
40
|
+
for (const key of ['action', 'canvasLight', 'inkDark', 'fontSans', 'fontMono', 'fontDisplay', 'shadowTint'])
|
|
41
|
+
requireValue(seeds[key] === undefined || typeof seeds[key] === 'string', `meta.seeds.${key} must be a string`);
|
|
42
|
+
for (const key of ['neutralHue', 'neutralChroma', 'radiusScale', 'baseFontSize', 'controlHeight'])
|
|
43
|
+
requireValue(seeds[key] === undefined || (typeof seeds[key] === 'number' && Number.isFinite(seeds[key])), `meta.seeds.${key} must be a finite number`);
|
|
44
|
+
requireValue(seeds.defaultTheme === undefined || seeds.defaultTheme === 'light' || seeds.defaultTheme === 'dark', 'meta.seeds.defaultTheme must be light or dark');
|
|
45
|
+
for (const key of ['overrides', 'presetOverrides'])
|
|
46
|
+
if (seeds[key] !== undefined)
|
|
47
|
+
record(seeds[key], `meta.seeds.${key}`);
|
|
48
|
+
const lint = record(meta.lint, 'meta.lint');
|
|
49
|
+
for (const key of ['include', 'exclude', 'allow'])
|
|
50
|
+
strings(lint[key], `meta.lint.${key}`);
|
|
51
|
+
requireValue(typeof lint.tailwind === 'boolean', 'meta.lint.tailwind must be a boolean');
|
|
52
|
+
const icons = record(meta.icons, 'meta.icons');
|
|
53
|
+
for (const key of ['set', 'strokeWidth', 'note'])
|
|
54
|
+
requireValue(typeof icons[key] === 'string', `meta.icons.${key} must be a string`);
|
|
55
|
+
requireValue(typeof meta.out === 'string' && meta.out.length > 0 && !isAbsolute(meta.out) && !meta.out.includes('\\') && meta.out.split('/').every((part) => part !== '.' && part !== '..' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(part)), 'meta.out must be a relative output directory');
|
|
56
|
+
requireValue(!['components', 'patterns', 'system.json', 'tokens.json'].includes(meta.out.split('/')[0]), 'meta.out must not overlap design source files');
|
|
57
|
+
// Keep an active Studio attached to its configured output; changing it requires a restart.
|
|
58
|
+
requireValue(resolve(designRoot, meta.out) === outputRoot, 'Studio cannot change meta.out; use the configured output directory');
|
|
59
|
+
}
|
|
60
|
+
export function contained(root, path) {
|
|
61
|
+
const rel = relative(root, path);
|
|
62
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))
|
|
63
|
+
throw new HttpError(403, 'Path is outside the Studio directory');
|
|
64
|
+
}
|
|
65
|
+
export function maybeStat(path) {
|
|
66
|
+
try {
|
|
67
|
+
return lstatSync(path);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code === 'ENOENT')
|
|
71
|
+
return undefined;
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Source/output writes reject symlinks, including dangling links and linked parents. */
|
|
76
|
+
export function sourcePath(root, path, kind) {
|
|
77
|
+
contained(root, path);
|
|
78
|
+
if (realpathSync(root) !== root)
|
|
79
|
+
throw new HttpError(403, 'The Studio design directory has moved');
|
|
80
|
+
const parts = relative(root, path).split(sep).filter(Boolean);
|
|
81
|
+
let current = root;
|
|
82
|
+
for (let i = -1; i < parts.length; i++) {
|
|
83
|
+
if (i >= 0)
|
|
84
|
+
current = join(current, parts[i]);
|
|
85
|
+
const info = maybeStat(current);
|
|
86
|
+
if (!info)
|
|
87
|
+
continue;
|
|
88
|
+
if (info.isSymbolicLink())
|
|
89
|
+
throw new HttpError(403, 'Studio does not read or write design files through symlinks');
|
|
90
|
+
const directory = i < parts.length - 1 || kind === 'directory';
|
|
91
|
+
if (directory ? !info.isDirectory() : !info.isFile())
|
|
92
|
+
throw new HttpError(409, 'A Studio path has the wrong file type');
|
|
93
|
+
}
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
export function loadSource(root) {
|
|
97
|
+
for (const file of ['system.json', 'tokens.json'])
|
|
98
|
+
sourcePath(root, join(root, file), 'file');
|
|
99
|
+
for (const folder of ['components', 'patterns']) {
|
|
100
|
+
const path = sourcePath(root, join(root, folder), 'directory');
|
|
101
|
+
if (maybeStat(path))
|
|
102
|
+
for (const file of readdirSync(path))
|
|
103
|
+
if (file.endsWith('.json'))
|
|
104
|
+
sourcePath(root, join(path, file), 'file');
|
|
105
|
+
}
|
|
106
|
+
return loadDesignDir(root);
|
|
107
|
+
}
|
|
108
|
+
/** Validate a complete imported snapshot before creating any source/output files. */
|
|
109
|
+
export function validateSnapshot(value) {
|
|
110
|
+
const source = record(value, 'Design snapshot');
|
|
111
|
+
const meta = record(source.meta, 'meta');
|
|
112
|
+
requireValue(typeof meta.out === 'string', 'meta.out must be a relative output directory');
|
|
113
|
+
const root = resolve('.');
|
|
114
|
+
validateMeta(meta, root, resolve(root, meta.out));
|
|
115
|
+
record(source.tokens, 'tokens');
|
|
116
|
+
for (const collection of ['components', 'patterns']) {
|
|
117
|
+
requireValue(Array.isArray(source[collection]), `${collection} must be an array`);
|
|
118
|
+
const slugs = new Set();
|
|
119
|
+
for (const item of source[collection]) {
|
|
120
|
+
const spec = record(item, `${collection} entry`);
|
|
121
|
+
identifier(spec.slug, `${collection} slug`);
|
|
122
|
+
requireValue(!slugs.has(spec.slug), `Duplicate ${collection} slug: ${spec.slug}`);
|
|
123
|
+
slugs.add(spec.slug);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const system = source;
|
|
127
|
+
const validation = validateSystem(system);
|
|
128
|
+
requireValue(!validation.errors.length, validation.errors.slice(0, 5).join('; '));
|
|
129
|
+
return system;
|
|
130
|
+
}
|
|
131
|
+
function replaceFile(root, write, content) {
|
|
132
|
+
sourcePath(write.root ?? root, write.path, 'file');
|
|
133
|
+
mkdirSync(dirname(write.path), { recursive: true });
|
|
134
|
+
const temporary = join(dirname(write.path), `.canon-${randomUUID()}.tmp`);
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(temporary, content, { flag: 'wx', mode: write.mode ?? 0o644 });
|
|
137
|
+
sourcePath(write.root ?? root, write.path, 'file');
|
|
138
|
+
renameSync(temporary, write.path);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
rmSync(temporary, { force: true });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Preflight all destinations before mutation, then replace files atomically and roll back errors. */
|
|
145
|
+
export function installFiles(root, writes) {
|
|
146
|
+
for (const write of writes) {
|
|
147
|
+
sourcePath(write.root ?? root, write.path, 'file');
|
|
148
|
+
const info = maybeStat(write.path);
|
|
149
|
+
if (info) {
|
|
150
|
+
write.previous = readFileSync(write.path);
|
|
151
|
+
write.mode = info.mode & 0o777;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const installed = [];
|
|
155
|
+
try {
|
|
156
|
+
for (const write of writes) {
|
|
157
|
+
replaceFile(write.root ?? root, write, write.content);
|
|
158
|
+
installed.push(write);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
for (const write of installed.reverse()) {
|
|
163
|
+
try {
|
|
164
|
+
if (write.previous !== undefined)
|
|
165
|
+
replaceFile(write.root ?? root, write, write.previous);
|
|
166
|
+
else {
|
|
167
|
+
sourcePath(write.root ?? root, write.path, 'file');
|
|
168
|
+
rmSync(write.path);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch { /* Preserve the original failure; never follow a changed filesystem boundary. */ }
|
|
172
|
+
}
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { copyFile, cp, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { basename, dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
const execute = promisify(execFile);
|
|
10
|
+
// Both src/distribution.ts and installed lib/distribution.mjs live one level below the package root.
|
|
11
|
+
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
let archive;
|
|
14
|
+
async function copyRuntime(source, destination) {
|
|
15
|
+
await cp(source, destination, {
|
|
16
|
+
recursive: true,
|
|
17
|
+
filter: async (path) => {
|
|
18
|
+
const info = await lstat(path);
|
|
19
|
+
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) {
|
|
20
|
+
throw new Error('Canon distribution requires regular package files; linked files cannot be included.');
|
|
21
|
+
}
|
|
22
|
+
return !basename(path).startsWith('.');
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async function run(command, args, cwd, temporary, label) {
|
|
27
|
+
try {
|
|
28
|
+
return await execute(command, args, {
|
|
29
|
+
cwd, timeout: 60_000, maxBuffer: 2 * 1024 * 1024, windowsHide: true,
|
|
30
|
+
env: {
|
|
31
|
+
...process.env, npm_config_ignore_scripts: 'true',
|
|
32
|
+
TMPDIR: temporary, TMP: temporary, TEMP: temporary, NODE_COMPILE_CACHE: join(temporary, 'node-compile-cache'),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const detail = error;
|
|
38
|
+
throw new Error(`${label} failed: ${detail.stderr?.trim() || detail.message}`, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function prepareArchive() {
|
|
42
|
+
const temporary = await mkdtemp(join(tmpdir(), 'canon-distribution-'));
|
|
43
|
+
try {
|
|
44
|
+
let runtime = join(packageRoot, 'lib');
|
|
45
|
+
if (existsSync(join(packageRoot, 'src', 'cli.ts'))) {
|
|
46
|
+
// Compile only Canon's own source with its known build script. Isolate output so
|
|
47
|
+
// downloading cannot replace lib/ beneath another running command or test.
|
|
48
|
+
const workspace = join(temporary, 'build');
|
|
49
|
+
await mkdir(join(workspace, 'scripts'), { recursive: true });
|
|
50
|
+
await copyRuntime(join(packageRoot, 'src'), join(workspace, 'src'));
|
|
51
|
+
await copyFile(join(packageRoot, 'scripts', 'build.mjs'), join(workspace, 'scripts', 'build.mjs'));
|
|
52
|
+
let compiler;
|
|
53
|
+
try {
|
|
54
|
+
compiler = dirname(require.resolve('typescript/package.json'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error('Preparing a Canon checkout download requires its TypeScript development dependency. Install the Canon checkout dependencies first.');
|
|
58
|
+
}
|
|
59
|
+
await mkdir(join(workspace, 'node_modules'), { recursive: true });
|
|
60
|
+
await symlink(compiler, join(workspace, 'node_modules', 'typescript'), 'junction');
|
|
61
|
+
await run(process.execPath, [join(workspace, 'scripts', 'build.mjs')], workspace, temporary, 'Canon runtime compilation');
|
|
62
|
+
runtime = join(workspace, 'lib');
|
|
63
|
+
}
|
|
64
|
+
const staging = join(temporary, 'package');
|
|
65
|
+
await mkdir(join(staging, 'bin'), { recursive: true });
|
|
66
|
+
await copyRuntime(join(packageRoot, 'bin', 'canon.js'), join(staging, 'bin', 'canon.js'));
|
|
67
|
+
await copyRuntime(runtime, join(staging, 'lib'));
|
|
68
|
+
await copyRuntime(join(packageRoot, 'README.md'), join(staging, 'README.md'));
|
|
69
|
+
for (const name of ['LICENSE', 'THIRD_PARTY_NOTICES.md']) {
|
|
70
|
+
await copyRuntime(join(packageRoot, name), join(staging, name));
|
|
71
|
+
}
|
|
72
|
+
const original = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'));
|
|
73
|
+
const metadata = {};
|
|
74
|
+
for (const key of ['name', 'version', 'description', 'type', 'engines', 'license', 'homepage', 'repository', 'bugs', 'keywords', 'publishConfig', 'dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']) {
|
|
75
|
+
if (original[key] !== undefined)
|
|
76
|
+
metadata[key] = original[key];
|
|
77
|
+
}
|
|
78
|
+
// An explicit staging tree excludes the served design, caller's project, dev
|
|
79
|
+
// dependencies, lifecycle hooks, npm configuration and checkout-only files.
|
|
80
|
+
metadata.bin = { canon: 'bin/canon.js' };
|
|
81
|
+
metadata.files = ['bin', 'lib', 'README.md', 'LICENSE', 'THIRD_PARTY_NOTICES.md'];
|
|
82
|
+
await writeFile(join(staging, 'package.json'), JSON.stringify(metadata, null, 2) + '\n');
|
|
83
|
+
const userConfig = join(temporary, 'user.npmrc');
|
|
84
|
+
const globalConfig = join(temporary, 'global.npmrc');
|
|
85
|
+
await writeFile(userConfig, '');
|
|
86
|
+
await writeFile(globalConfig, '');
|
|
87
|
+
const { stdout } = await run('npm', [
|
|
88
|
+
'pack', '--ignore-scripts', '--json', '--offline', '--pack-destination', temporary,
|
|
89
|
+
'--userconfig', userConfig, '--globalconfig', globalConfig, '--cache', join(temporary, 'npm-cache'),
|
|
90
|
+
], staging, temporary, 'Canon package preparation');
|
|
91
|
+
const packed = JSON.parse(stdout);
|
|
92
|
+
const filename = packed[0]?.filename;
|
|
93
|
+
if (typeof filename !== 'string' || !/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
|
|
94
|
+
throw new Error('npm pack did not return a valid Canon archive filename.');
|
|
95
|
+
}
|
|
96
|
+
return await readFile(join(temporary, filename));
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
await rm(temporary, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Prepare lazily, share concurrent downloads, and allow a retry after preparation fails. */
|
|
103
|
+
export function getCanonPackage() {
|
|
104
|
+
archive ??= prepareArchive().catch((error) => {
|
|
105
|
+
archive = undefined;
|
|
106
|
+
throw error;
|
|
107
|
+
});
|
|
108
|
+
return archive;
|
|
109
|
+
}
|