@sous-io/sous 0.1.1 → 0.2.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/README.md +115 -35
- package/bin/run.js +10 -1
- package/docs/markdown/README.md +27 -0
- package/docs/markdown/_sidebar.md +18 -0
- package/docs/markdown/commands.md +308 -0
- package/docs/markdown/config-discovery.md +74 -0
- package/docs/markdown/config-inspection.md +69 -0
- package/docs/markdown/config-layers.md +92 -0
- package/docs/markdown/config-variables.md +79 -0
- package/docs/markdown/configuration.md +71 -0
- package/docs/markdown/design-principles.md +59 -0
- package/docs/markdown/repositories-authoring.md +408 -0
- package/docs/markdown/repositories-consuming.md +580 -0
- package/docs/markdown/repositories-file-formats.md +1084 -0
- package/docs/markdown/repositories-variables.md +387 -0
- package/docs/markdown/repositories.md +303 -0
- package/docs/markdown/skill-categories.md +58 -0
- package/package.json +72 -8
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
- package/sous.config.schema.json +337 -0
- package/src/base-command.ts +220 -67
- package/src/commands/build.ts +150 -73
- package/src/commands/clear.ts +23 -15
- package/src/commands/compile.ts +74 -16
- package/src/commands/config/get.ts +110 -0
- package/src/commands/config/show.ts +32 -0
- package/src/commands/config/validate.ts +53 -0
- package/src/commands/help.ts +46 -0
- package/src/commands/launch.ts +36 -14
- package/src/commands/lock/rebuild.ts +241 -0
- package/src/commands/lock/show.ts +115 -0
- package/src/commands/namespace/list.ts +117 -0
- package/src/commands/namespace/show.ts +110 -0
- package/src/commands/prune.ts +3 -11
- package/src/commands/recipe/list.ts +95 -0
- package/src/commands/recipe/show.ts +301 -0
- package/src/commands/repo/add.ts +145 -0
- package/src/commands/repo/gc.ts +172 -0
- package/src/commands/repo/init.ts +136 -0
- package/src/commands/repo/link.ts +500 -0
- package/src/commands/repo/list.ts +179 -0
- package/src/commands/repo/release.ts +619 -0
- package/src/commands/repo/remove.ts +193 -0
- package/src/commands/repo/search.ts +189 -0
- package/src/commands/repo/submit.ts +133 -0
- package/src/commands/repo/unlink.ts +147 -0
- package/src/commands/subscription/add.ts +285 -0
- package/src/commands/subscription/list.ts +129 -0
- package/src/commands/subscription/remove.ts +181 -0
- package/src/commands/vars/ask.ts +374 -0
- package/src/commands/vars/index.ts +79 -0
- package/src/commands/vars/list.ts +67 -0
- package/src/commands/vars/show.ts +77 -0
- package/src/config-command.ts +30 -0
- package/src/lib/build-service.ts +206 -54
- package/src/lib/config-discovery.ts +220 -27
- package/src/lib/config-inspect.ts +145 -0
- package/src/lib/config-kernel.mjs +377 -0
- package/src/lib/config-schema.ts +361 -0
- package/src/lib/env-file.ts +328 -0
- package/src/lib/env-local.ts +18 -1
- package/src/lib/errors.ts +32 -0
- package/src/lib/include-resolver.ts +108 -15
- package/src/lib/interactive.ts +165 -0
- package/src/lib/markdown-compiler.ts +118 -37
- package/src/lib/package-info.ts +25 -0
- package/src/lib/pid-service.ts +32 -21
- package/src/lib/refs/find.ts +589 -0
- package/src/lib/refs/index.ts +12 -0
- package/src/lib/refs/pick.ts +147 -0
- package/src/lib/refs/scopes.ts +61 -0
- package/src/lib/repos/catalog-display.ts +116 -0
- package/src/lib/repos/catalog-inputs.ts +160 -0
- package/src/lib/repos/catalog.ts +722 -0
- package/src/lib/repos/core-recipe.ts +105 -0
- package/src/lib/repos/defaults.ts +175 -0
- package/src/lib/repos/formats/common.ts +389 -0
- package/src/lib/repos/formats/index-file.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +96 -0
- package/src/lib/repos/formats/lockfile.ts +167 -0
- package/src/lib/repos/formats/patterns.ts +57 -0
- package/src/lib/repos/formats/recipe-manifest.ts +395 -0
- package/src/lib/repos/formats/repo-manifest.ts +88 -0
- package/src/lib/repos/formats/store-entry.ts +84 -0
- package/src/lib/repos/freshness.ts +208 -0
- package/src/lib/repos/git-clone.ts +312 -0
- package/src/lib/repos/identity.ts +89 -0
- package/src/lib/repos/index.ts +58 -0
- package/src/lib/repos/links.ts +353 -0
- package/src/lib/repos/load-manifest.ts +236 -0
- package/src/lib/repos/lock-service.ts +453 -0
- package/src/lib/repos/locked-namespace-resolver.ts +90 -0
- package/src/lib/repos/locked-recipes.ts +254 -0
- package/src/lib/repos/managed-layer.ts +422 -0
- package/src/lib/repos/namespace-resolver.ts +370 -0
- package/src/lib/repos/providers/base.ts +206 -0
- package/src/lib/repos/providers/git.ts +233 -0
- package/src/lib/repos/providers/github.ts +294 -0
- package/src/lib/repos/providers/gitlab.ts +263 -0
- package/src/lib/repos/providers/http.ts +102 -0
- package/src/lib/repos/providers/index-cache.ts +382 -0
- package/src/lib/repos/providers/index.ts +106 -0
- package/src/lib/repos/providers/local.ts +391 -0
- package/src/lib/repos/providers/provider.ts +401 -0
- package/src/lib/repos/recipe-config-layers.ts +287 -0
- package/src/lib/repos/recipe-targets.ts +223 -0
- package/src/lib/repos/ref-search.ts +46 -0
- package/src/lib/repos/ref.ts +513 -0
- package/src/lib/repos/reference-report.ts +122 -0
- package/src/lib/repos/release/bump.ts +161 -0
- package/src/lib/repos/release/git-state.ts +305 -0
- package/src/lib/repos/release/index-builder.ts +635 -0
- package/src/lib/repos/release/index.ts +16 -0
- package/src/lib/repos/release/plan.ts +512 -0
- package/src/lib/repos/release/submit-service.ts +496 -0
- package/src/lib/repos/release/tags.ts +243 -0
- package/src/lib/repos/release/validate.ts +463 -0
- package/src/lib/repos/resolver.ts +789 -0
- package/src/lib/repos/scaffold/index.ts +238 -0
- package/src/lib/repos/scaffold/templates.ts +413 -0
- package/src/lib/repos/seed.ts +414 -0
- package/src/lib/repos/store/contract.ts +64 -0
- package/src/lib/repos/store/hash.ts +114 -0
- package/src/lib/repos/store/recipe-store.ts +599 -0
- package/src/lib/repos/store/settings.ts +58 -0
- package/src/lib/repos/subscription-service.ts +2678 -0
- package/src/lib/repos/trust.ts +447 -0
- package/src/lib/settings.ts +546 -189
- package/src/lib/sous-home.ts +104 -0
- package/src/lib/state.ts +52 -20
- package/src/lib/vars/ask.ts +1152 -0
- package/src/lib/vars/definition-source.ts +252 -0
- package/src/lib/vars/display.ts +233 -0
- package/src/lib/vars/index.ts +18 -0
- package/src/lib/vars/ladder.ts +282 -0
- package/src/lib/vars/mappings.ts +265 -0
- package/src/lib/vars/names.ts +94 -0
- package/src/lib/vars/preanswers.ts +395 -0
- package/src/lib/vars/question-plan.ts +218 -0
- package/src/lib/vars/report.ts +228 -0
- package/src/lib/vars/safe-regex.ts +235 -0
- package/src/lib/vars/validate.ts +312 -0
- package/src/lib/watch-loop.ts +148 -0
- package/src/templating/init-liquid-engine.ts +58 -16
- package/src/utils/choice-prompt.ts +143 -0
- package/src/utils/command-errors.ts +186 -0
- package/src/utils/command-help.ts +45 -0
- package/src/utils/confirm-prompt.ts +110 -0
- package/src/utils/flags.ts +153 -0
- package/src/utils/formatting.ts +540 -55
- package/src/utils/prompts.ts +35 -1
- package/src/utils/sous-directory.ts +245 -0
- package/src/utils/table.ts +603 -0
- package/src/utils/value-prompt.ts +119 -0
- package/shared-prompts/_partials/resume-task.md +0 -51
- package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
- package/shared-prompts/_partials/update-task-file.md +0 -52
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
package/src/lib/env-local.ts
CHANGED
|
@@ -83,6 +83,23 @@ function findClosingQuote(value: string, quote: string): number {
|
|
|
83
83
|
return -1;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Reads one env file and returns its variables as a flat map, WITHOUT touching
|
|
88
|
+
* `process.env`. Returns an empty map when the file does not exist.
|
|
89
|
+
*
|
|
90
|
+
* The variables layer needs each env file on its own: by the time a command
|
|
91
|
+
* runs, `loadEnvFiles` has already merged both files into the process
|
|
92
|
+
* environment, so reading them separately is the only way to say which layer
|
|
93
|
+
* supplied an answer.
|
|
94
|
+
*
|
|
95
|
+
* @param filePath - Absolute path to the env file.
|
|
96
|
+
* @returns The parsed variables, or an empty map when there is no such file.
|
|
97
|
+
*/
|
|
98
|
+
export function readEnvFileMap(filePath: string): Record<string, string> {
|
|
99
|
+
if (!fs.existsSync(filePath)) return {};
|
|
100
|
+
return parseEnvLocal(fs.readFileSync(filePath, "utf8"));
|
|
101
|
+
}
|
|
102
|
+
|
|
86
103
|
/** The outcome of an attempted env file load. */
|
|
87
104
|
export type EnvLocalLoadResult = {
|
|
88
105
|
/** Absolute path checked. */
|
|
@@ -155,7 +172,7 @@ export function loadEnvDefaults(
|
|
|
155
172
|
* `process.env`, if it exists.
|
|
156
173
|
*
|
|
157
174
|
* Existing environment values always win: a variable already set in the real
|
|
158
|
-
* environment is never overwritten, so `FOO=bar
|
|
175
|
+
* environment is never overwritten, so `FOO=bar sous build` behaves as expected.
|
|
159
176
|
*
|
|
160
177
|
* @param sousDir - The discovered `.sous/` directory.
|
|
161
178
|
* @param env - The environment object to mutate (injectable for tests).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared error types for sous.
|
|
3
|
+
*
|
|
4
|
+
* ConfigError lives in its own module (rather than settings.ts) so that
|
|
5
|
+
* config-discovery.ts can throw it without importing settings.ts — settings.ts
|
|
6
|
+
* imports discovery constants, and a back-import would create a cycle.
|
|
7
|
+
* settings.ts re-exports both names for backwards compatibility.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A user-facing configuration error: the config file (or environment) is wrong,
|
|
12
|
+
* not the CLI. Commands render these as a plain message with no stack trace,
|
|
13
|
+
* since the stack points at Sous internals and tells the user nothing.
|
|
14
|
+
*/
|
|
15
|
+
export class ConfigError extends Error {
|
|
16
|
+
readonly isConfigError = true;
|
|
17
|
+
|
|
18
|
+
constructor(message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ConfigError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True when the value is a ConfigError (safe across module instances). */
|
|
25
|
+
export function isConfigError(error: unknown): boolean {
|
|
26
|
+
return (
|
|
27
|
+
error instanceof ConfigError ||
|
|
28
|
+
(typeof error === "object" &&
|
|
29
|
+
error !== null &&
|
|
30
|
+
(error as { isConfigError?: boolean }).isConfigError === true)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import type { NamespaceResolution, NamespaceResolver } from "./repos/namespace-resolver.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* @include path resolution: aliases, variable substitution,
|
|
5
|
-
* candidate search.
|
|
5
|
+
* @include path resolution: aliases, recipe namespaces, variable substitution,
|
|
6
|
+
* and the ordered candidate search.
|
|
6
7
|
*
|
|
7
8
|
* An include path (the part after `@`) is resolved to an ordered list of
|
|
8
9
|
* candidate absolute paths. The caller tries each in order and uses the first
|
|
@@ -14,11 +15,19 @@ import path from "node:path";
|
|
|
14
15
|
* 2. Split the first segment (up to the first `/` or `:`) as the alias key,
|
|
15
16
|
* the remainder as `rest`. If the key is a registered alias, push
|
|
16
17
|
* join(base, rest) for EACH base in the alias's ordered array.
|
|
17
|
-
* 3.
|
|
18
|
+
* 3. If the key begins with `~` and a namespace resolver was supplied, ask it
|
|
19
|
+
* for the recipe namespace named by the key (minus the `~`) and push its
|
|
20
|
+
* candidates. Aliases are consulted first, so `~project` keeps meaning
|
|
21
|
+
* the built-in alias even if a namespace of that name exists.
|
|
22
|
+
* 4. Always push the relative candidate: join(baseDir, P) — the FULL path
|
|
18
23
|
* including the alias segment. This lets an alias augment a real relative
|
|
19
24
|
* directory of the same name (e.g. `@stuff/x` tries the alias bases, then
|
|
20
25
|
* `./stuff/x`).
|
|
21
26
|
*
|
|
27
|
+
* A key WITHOUT the `~` sigil never reaches the namespace resolver: a bare
|
|
28
|
+
* `@path` is always a relative path or a declared alias, so include lines never
|
|
29
|
+
* masquerade as filesystem paths.
|
|
30
|
+
*
|
|
22
31
|
* Aliases whose names begin with `~` are reserved for built-ins; user aliases
|
|
23
32
|
* may not use that prefix. The primary separator is `/` (TS-style,
|
|
24
33
|
* `@alias/path`); `:` is accepted as an equivalent (`@alias:path`).
|
|
@@ -52,29 +61,69 @@ export function splitAliasKey(p: string): { key: string; rest: string } {
|
|
|
52
61
|
return { key: m[1], rest: m[2] };
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
/** Options shared by {@link resolveInclude} and {@link resolveIncludeCandidates}. */
|
|
65
|
+
export type IncludeResolveOptions = {
|
|
66
|
+
/** The resolved alias map (name → ordered base dirs). */
|
|
67
|
+
aliases?: AliasMap;
|
|
68
|
+
/** Variable scope for ${var} substitution. */
|
|
69
|
+
scope?: Record<string, string>;
|
|
70
|
+
/** Directory of the including file (for the relative candidate). */
|
|
71
|
+
baseDir: string;
|
|
72
|
+
/** Resolver consulted for a `~namespace` first segment; omitted means namespaces are unavailable. */
|
|
73
|
+
namespaceResolver?: NamespaceResolver;
|
|
74
|
+
/**
|
|
75
|
+
* Absolute path of the including file, handed to the namespace resolver so it
|
|
76
|
+
* can tell whether the include comes from inside a recipe. Defaults to
|
|
77
|
+
* `baseDir` for callers that only know the directory.
|
|
78
|
+
*/
|
|
79
|
+
fromFile?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** A namespace lookup that produced no usable candidates, kept for error reporting. */
|
|
83
|
+
export type NamespaceIssue = {
|
|
84
|
+
/** The namespace that was asked for, without its `~` sigil. */
|
|
85
|
+
namespace: string;
|
|
86
|
+
/** The remainder of the reference (recipe name plus the path inside it). */
|
|
87
|
+
rest: string;
|
|
88
|
+
/** The file (or directory) that performed the include. */
|
|
89
|
+
fromFile: string;
|
|
90
|
+
/** What the resolver returned. */
|
|
91
|
+
resolution: NamespaceResolution;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The full result of resolving one include path. */
|
|
95
|
+
export type IncludeResolution = {
|
|
96
|
+
/** Ordered, de-duplicated absolute candidate paths. */
|
|
97
|
+
candidates: string[];
|
|
98
|
+
/**
|
|
99
|
+
* Present when the first segment named a `~namespace` that the resolver could
|
|
100
|
+
* not satisfy. The candidate list is still usable (it holds the alias and
|
|
101
|
+
* relative fallbacks); this only explains what went wrong with the namespace.
|
|
102
|
+
*/
|
|
103
|
+
namespaceIssue?: NamespaceIssue;
|
|
104
|
+
};
|
|
105
|
+
|
|
55
106
|
/**
|
|
56
|
-
*
|
|
107
|
+
* Resolve an include path to its ordered candidates plus any namespace
|
|
108
|
+
* diagnostic. Use this when the caller wants to report WHY a `~namespace`
|
|
109
|
+
* reference failed; {@link resolveIncludeCandidates} is the plain-list form.
|
|
57
110
|
*
|
|
58
111
|
* @param rawPath - The include path with the leading `@` already stripped.
|
|
59
|
-
* @param opts
|
|
60
|
-
* @
|
|
61
|
-
* @param opts.baseDir - Directory of the including file (for the relative candidate).
|
|
62
|
-
* @returns Ordered, de-duplicated absolute candidate paths.
|
|
112
|
+
* @param opts - Aliases, variable scope, including directory and optional namespace resolver.
|
|
113
|
+
* @returns The candidate list and, when a namespace lookup failed, the reason.
|
|
63
114
|
*/
|
|
64
|
-
export function
|
|
65
|
-
rawPath: string,
|
|
66
|
-
opts: { aliases?: AliasMap; scope?: Record<string, string>; baseDir: string }
|
|
67
|
-
): string[] {
|
|
115
|
+
export function resolveInclude(rawPath: string, opts: IncludeResolveOptions): IncludeResolution {
|
|
68
116
|
const aliases = opts.aliases ?? {};
|
|
69
117
|
const scope = opts.scope ?? {};
|
|
70
118
|
const substituted = substituteVars(rawPath, scope);
|
|
71
119
|
|
|
72
120
|
// 1. Substituted to an absolute path → that is the only candidate.
|
|
73
121
|
if (path.isAbsolute(substituted)) {
|
|
74
|
-
return [path.normalize(substituted)];
|
|
122
|
+
return { candidates: [path.normalize(substituted)] };
|
|
75
123
|
}
|
|
76
124
|
|
|
77
125
|
const candidates: string[] = [];
|
|
126
|
+
let namespaceIssue: NamespaceIssue | undefined;
|
|
78
127
|
|
|
79
128
|
// 2. Alias bases (ordered), if the first segment is a registered alias.
|
|
80
129
|
const { key, rest } = splitAliasKey(substituted);
|
|
@@ -84,11 +133,55 @@ export function resolveIncludeCandidates(
|
|
|
84
133
|
}
|
|
85
134
|
}
|
|
86
135
|
|
|
87
|
-
// 3.
|
|
136
|
+
// 3. Recipe namespace, only for a `~`-sigil key naming something after it.
|
|
137
|
+
// Aliases above already had their turn, so a built-in or user alias of the
|
|
138
|
+
// same name is always preferred.
|
|
139
|
+
if (opts.namespaceResolver && key.startsWith("~") && key.length > 1 && rest.length > 0) {
|
|
140
|
+
const namespace = key.slice(1);
|
|
141
|
+
const fromFile = opts.fromFile ?? opts.baseDir;
|
|
142
|
+
const resolution = opts.namespaceResolver.resolve({ namespace, rest, fromFile });
|
|
143
|
+
|
|
144
|
+
if (resolution.kind === "candidates" && resolution.candidates.length > 0) {
|
|
145
|
+
candidates.push(...resolution.candidates.map((c) => path.normalize(c)));
|
|
146
|
+
} else {
|
|
147
|
+
namespaceIssue = { namespace, rest, fromFile, resolution };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 4. Relative fallback: the FULL substituted path under the including dir.
|
|
88
152
|
candidates.push(path.resolve(opts.baseDir, substituted));
|
|
89
153
|
|
|
90
154
|
// De-dupe, preserving order.
|
|
91
|
-
return [...new Set(candidates)];
|
|
155
|
+
return { candidates: [...new Set(candidates)], namespaceIssue };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Compute the ordered list of candidate absolute paths for an include.
|
|
160
|
+
*
|
|
161
|
+
* @param rawPath - The include path with the leading `@` already stripped.
|
|
162
|
+
* @param opts - Aliases, variable scope, including directory and optional namespace resolver.
|
|
163
|
+
* @returns Ordered, de-duplicated absolute candidate paths.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveIncludeCandidates(rawPath: string, opts: IncludeResolveOptions): string[] {
|
|
166
|
+
return resolveInclude(rawPath, opts).candidates;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Expand a leading alias segment in a path or glob pattern to one candidate
|
|
171
|
+
* per alias base, in the alias's base order. Used for config entry paths
|
|
172
|
+
* (`entryGlob`/watch patterns), where — unlike @include resolution — there is
|
|
173
|
+
* no including file to supply a relative fallback, so a non-alias path is
|
|
174
|
+
* returned unchanged as the sole candidate.
|
|
175
|
+
*
|
|
176
|
+
* @param p - The path or glob pattern (already ${var}-substituted).
|
|
177
|
+
* @param aliases - The resolved alias map (name → ordered base dirs).
|
|
178
|
+
* @returns Ordered candidate paths/patterns; `[p]` when no alias applies.
|
|
179
|
+
*/
|
|
180
|
+
export function resolveAliasPrefix(p: string, aliases: AliasMap): string[] {
|
|
181
|
+
if (path.isAbsolute(p)) return [p];
|
|
182
|
+
const { key, rest } = splitAliasKey(p);
|
|
183
|
+
if (!key || !Object.prototype.hasOwnProperty.call(aliases, key)) return [p];
|
|
184
|
+
return aliases[key].map((base) => path.resolve(base, rest));
|
|
92
185
|
}
|
|
93
186
|
|
|
94
187
|
/**
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rule for "may sous ask a question right now?".
|
|
3
|
+
*
|
|
4
|
+
* Every prompt in sous (the trust question, the subscribe confirmation, the
|
|
5
|
+
* choice between candidate refs, a variable question) is gated by this single
|
|
6
|
+
* predicate, so a run either can ask all of them or none of them. Three things
|
|
7
|
+
* can take the terminal away:
|
|
8
|
+
*
|
|
9
|
+
* - the global `--non-interactive` flag, which says so outright;
|
|
10
|
+
* - a truthy `CI` environment variable, which every continuous integration
|
|
11
|
+
* runner sets and which means no human is watching;
|
|
12
|
+
* - stdin or stdout not being a terminal, which is what piping or scripting
|
|
13
|
+
* a command looks like from in here.
|
|
14
|
+
*
|
|
15
|
+
* When a prompt cannot be shown, the run fails rather than guessing, and the
|
|
16
|
+
* failure names both the question that could not be asked and the flag (or the
|
|
17
|
+
* environment variables) that would have answered it ahead of time. Every
|
|
18
|
+
* confirmation in sous is answered by one shared flag, `--yes` (also `-y`,
|
|
19
|
+
* `--force`, and `--trust` on the commands that trust a repository), so a
|
|
20
|
+
* remedy names that flag and lists its other spellings once. The command layer
|
|
21
|
+
* prints the command's own help underneath that error, so the caller can see
|
|
22
|
+
* every flag without going looking; `sous help <command>` prints the same
|
|
23
|
+
* screen on demand.
|
|
24
|
+
*
|
|
25
|
+
* Every input is injectable, so a test can describe a terminal, a pipe or a CI
|
|
26
|
+
* runner without touching the real process.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { ConfigError } from "./errors.js";
|
|
30
|
+
|
|
31
|
+
/** Everything the rule reads. Each field defaults to the real process. */
|
|
32
|
+
export type InteractiveInputs = {
|
|
33
|
+
/** The command line, scanned for `--non-interactive`. Defaults to `process.argv`. */
|
|
34
|
+
argv?: string[];
|
|
35
|
+
/** The environment, read for `CI`. Defaults to `process.env`. */
|
|
36
|
+
env?: NodeJS.ProcessEnv;
|
|
37
|
+
/** Whether stdin is a terminal. Defaults to the real stdin. */
|
|
38
|
+
stdinIsTTY?: boolean;
|
|
39
|
+
/** Whether stdout is a terminal. Defaults to the real stdout. */
|
|
40
|
+
stdoutIsTTY?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The global flag that turns every prompt into an error. */
|
|
44
|
+
export const NON_INTERACTIVE_FLAG = "--non-interactive";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Values of `CI` that mean "not set". Everything else counts as set, because
|
|
48
|
+
* runners spell it `1`, `true` and `yes` in roughly equal measure and a run
|
|
49
|
+
* that guesses wrong hangs forever waiting on a question nobody can see.
|
|
50
|
+
*/
|
|
51
|
+
const FALSY_CI_VALUES = new Set(["", "0", "false", "no", "off"]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when sous is attached to a terminal in both directions, was not told to
|
|
55
|
+
* stay quiet, and is not running inside a continuous integration runner.
|
|
56
|
+
*
|
|
57
|
+
* @param inputs - Overrides for the command line, environment and streams.
|
|
58
|
+
*/
|
|
59
|
+
export function isInteractive(inputs: InteractiveInputs = {}): boolean {
|
|
60
|
+
return nonInteractiveReason(inputs) === undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Why this run cannot ask a question, as a plain sentence, or undefined when it
|
|
65
|
+
* can. The reason is quoted in the error a blocked prompt raises, because "sous
|
|
66
|
+
* is not running where it can ask" is baffling on its own when the caller is
|
|
67
|
+
* sitting at a terminal and only `CI=1` in their shell made it true.
|
|
68
|
+
*
|
|
69
|
+
* @param inputs - Overrides for the command line, environment and streams.
|
|
70
|
+
*/
|
|
71
|
+
export function nonInteractiveReason(inputs: InteractiveInputs = {}): string | undefined {
|
|
72
|
+
const argv = inputs.argv ?? process.argv;
|
|
73
|
+
const env = inputs.env ?? process.env;
|
|
74
|
+
const stdinIsTTY = inputs.stdinIsTTY ?? process.stdin.isTTY === true;
|
|
75
|
+
const stdoutIsTTY = inputs.stdoutIsTTY ?? process.stdout.isTTY === true;
|
|
76
|
+
|
|
77
|
+
if (hasNonInteractiveFlag(argv)) {
|
|
78
|
+
return `the '${NON_INTERACTIVE_FLAG}' flag was passed`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ci = env.CI;
|
|
82
|
+
if (ci !== undefined && !FALSY_CI_VALUES.has(ci.trim().toLowerCase())) {
|
|
83
|
+
return `the 'CI' environment variable is set to '${ci}'`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!stdinIsTTY && !stdoutIsTTY) return "neither input nor output is a terminal";
|
|
87
|
+
if (!stdinIsTTY) return "input is not a terminal";
|
|
88
|
+
if (!stdoutIsTTY) return "output is not a terminal";
|
|
89
|
+
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* True when the command line carries `--non-interactive`. Scanning stops at a
|
|
95
|
+
* bare `--`, exactly as the config-flag readers do: anything after it belongs to
|
|
96
|
+
* a launched tool, never to sous.
|
|
97
|
+
*
|
|
98
|
+
* @param argv - The raw command line.
|
|
99
|
+
*/
|
|
100
|
+
function hasNonInteractiveFlag(argv: string[]): boolean {
|
|
101
|
+
for (const arg of argv) {
|
|
102
|
+
if (arg === "--") return false;
|
|
103
|
+
if (arg === NON_INTERACTIVE_FLAG) return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A ConfigError raised because a question could not be asked. It carries a flag
|
|
110
|
+
* telling the command layer to print the command's own help underneath it, so
|
|
111
|
+
* the caller sees every flag that could have answered the question.
|
|
112
|
+
*/
|
|
113
|
+
export class NonInteractiveError extends ConfigError {
|
|
114
|
+
/** Tells `BaseCommand` to print the command's help after this error. */
|
|
115
|
+
readonly showHelp = true;
|
|
116
|
+
|
|
117
|
+
constructor(message: string) {
|
|
118
|
+
super(message);
|
|
119
|
+
this.name = "NonInteractiveError";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True when an error asked for the command's help to be printed with it. */
|
|
124
|
+
export function wantsHelp(error: unknown): boolean {
|
|
125
|
+
return (
|
|
126
|
+
typeof error === "object" &&
|
|
127
|
+
error !== null &&
|
|
128
|
+
(error as { showHelp?: boolean }).showHelp === true
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What a blocked prompt needs to describe itself. */
|
|
133
|
+
export type BlockedPrompt = {
|
|
134
|
+
/** The question that could not be asked, named as a person would name it. */
|
|
135
|
+
prompt: string;
|
|
136
|
+
/**
|
|
137
|
+
* How to answer it ahead of time: a flag, or the environment variables. A
|
|
138
|
+
* remedy names the flag's primary spelling, and lists its alternate spellings
|
|
139
|
+
* once, in one parenthetical; the alternates are defined in
|
|
140
|
+
* `utils/flags.ts` and the command's own help (printed underneath this error)
|
|
141
|
+
* lists them too.
|
|
142
|
+
*/
|
|
143
|
+
remedy: string;
|
|
144
|
+
/** Extra lines of context, such as the candidates that could not be chosen between. */
|
|
145
|
+
details?: string[];
|
|
146
|
+
/** Overrides for the command line, environment and streams. */
|
|
147
|
+
inputs?: InteractiveInputs;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Builds the error a blocked prompt raises: what could not be asked, why sous
|
|
152
|
+
* could not ask it, and what would have answered it without a terminal.
|
|
153
|
+
*
|
|
154
|
+
* @param blocked - The prompt, the remedy and any extra context.
|
|
155
|
+
*/
|
|
156
|
+
export function nonInteractiveError(blocked: BlockedPrompt): NonInteractiveError {
|
|
157
|
+
const reason = nonInteractiveReason(blocked.inputs ?? {}) ?? "there is no terminal to ask on";
|
|
158
|
+
const lines = [
|
|
159
|
+
`Sous has to ask ${blocked.prompt}, and it is not running where it can ask.`,
|
|
160
|
+
` Why: ${reason}.`,
|
|
161
|
+
];
|
|
162
|
+
for (const detail of blocked.details ?? []) lines.push(` ${detail}`);
|
|
163
|
+
lines.push(` Answer it ahead of time: ${blocked.remedy}`);
|
|
164
|
+
return new NonInteractiveError(lines.join("\n"));
|
|
165
|
+
}
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { get_encoding, type Tiktoken } from "tiktoken";
|
|
5
|
-
import { displayError, log,
|
|
5
|
+
import { displayError, log, showVariable, subheading, warning } from "../utils/formatting.js";
|
|
6
6
|
import { createLiquidEngine } from "../templating/init-liquid-engine.js";
|
|
7
7
|
import {
|
|
8
8
|
StateService,
|
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
hashContent,
|
|
12
12
|
recordDirCreation,
|
|
13
13
|
} from "./state.js";
|
|
14
|
-
import {
|
|
14
|
+
import { resolveInclude, type AliasMap } from "./include-resolver.js";
|
|
15
|
+
import {
|
|
16
|
+
formatNamespaceProblem,
|
|
17
|
+
type NamespaceResolver,
|
|
18
|
+
} from "./repos/namespace-resolver.js";
|
|
15
19
|
|
|
16
20
|
export type ResolvedOutput = {
|
|
17
21
|
destinationFile?: string;
|
|
@@ -62,6 +66,13 @@ export type CompilationServiceOptions = {
|
|
|
62
66
|
strict?: boolean;
|
|
63
67
|
rebuild?: boolean;
|
|
64
68
|
dryRun?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Resolves `@~<namespace>/<recipe>/<path>` includes and the matching
|
|
71
|
+
* `{% render %}` paths against recipe namespaces. Omit it and the `~` sigil
|
|
72
|
+
* only ever means an alias, which is the behavior for projects that use no
|
|
73
|
+
* repositories.
|
|
74
|
+
*/
|
|
75
|
+
namespaceResolver?: NamespaceResolver;
|
|
65
76
|
};
|
|
66
77
|
|
|
67
78
|
/**
|
|
@@ -73,6 +84,36 @@ export type CompilationServiceOptions = {
|
|
|
73
84
|
* "/foo/bar/*\/baz.md" → "/foo/bar"
|
|
74
85
|
* "/**\/*" → "/"
|
|
75
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* The file one output of one target writes, or undefined when the output names
|
|
89
|
+
* neither a destination file nor a destination directory.
|
|
90
|
+
*
|
|
91
|
+
* A `destinationFile` is used as it stands. A `destinationDir` mirrors the
|
|
92
|
+
* source tree underneath it, relative to the target's `globBase`, with `.tpl.`
|
|
93
|
+
* stripped from the name.
|
|
94
|
+
*
|
|
95
|
+
* Exported because prune needs the same answer without compiling: a group of
|
|
96
|
+
* targets that share one destination directory (every subscribed recipe writing
|
|
97
|
+
* into `.claude/skills`, say) can only be pruned precisely if the exact set of
|
|
98
|
+
* files they write is known.
|
|
99
|
+
*
|
|
100
|
+
* @param target - The compilation target.
|
|
101
|
+
* @param output - One of its outputs.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveOutputPath(
|
|
104
|
+
target: Pick<CompilationTarget, "rootInputPath" | "globBase">,
|
|
105
|
+
output: ResolvedOutput
|
|
106
|
+
): string | undefined {
|
|
107
|
+
if (output.destinationFile) return output.destinationFile;
|
|
108
|
+
if (!output.destinationDir) return undefined;
|
|
109
|
+
|
|
110
|
+
const sourceRelative = target.globBase
|
|
111
|
+
? path.relative(target.globBase, target.rootInputPath)
|
|
112
|
+
: path.basename(target.rootInputPath);
|
|
113
|
+
|
|
114
|
+
return path.join(output.destinationDir, sourceRelative.replace(/\.tpl\./, "."));
|
|
115
|
+
}
|
|
116
|
+
|
|
76
117
|
export function inferGlobBase(pattern: string): string {
|
|
77
118
|
const parts = pattern.split("/");
|
|
78
119
|
const staticParts: string[] = [];
|
|
@@ -101,6 +142,7 @@ export class CompilationService {
|
|
|
101
142
|
private numberFormatter: Intl.NumberFormat;
|
|
102
143
|
private aliases: AliasMap;
|
|
103
144
|
private includeScope: Record<string, string>;
|
|
145
|
+
private namespaceResolver?: NamespaceResolver;
|
|
104
146
|
/**
|
|
105
147
|
* `.tpl.` outputs that were written without a variable scope, so LiquidJS never
|
|
106
148
|
* ran and the template shipped with its tags intact. Reported at the end of the
|
|
@@ -121,6 +163,7 @@ export class CompilationService {
|
|
|
121
163
|
this.numberFormatter = new Intl.NumberFormat("en-US");
|
|
122
164
|
this.aliases = {};
|
|
123
165
|
this.includeScope = {};
|
|
166
|
+
this.namespaceResolver = options.namespaceResolver;
|
|
124
167
|
this.unrenderedTemplates = [];
|
|
125
168
|
}
|
|
126
169
|
|
|
@@ -146,19 +189,35 @@ export class CompilationService {
|
|
|
146
189
|
* Matches an `@`-prefixed `.md` path on its own line. The path may be:
|
|
147
190
|
* - relative to the including file (`@sections/intro.md`),
|
|
148
191
|
* - a `${var}`-substituted path (`@${sousRootPath}/x.md`),
|
|
149
|
-
* -
|
|
150
|
-
* first segment (up to `/` or `:`) names a registered alias
|
|
192
|
+
* - an alias path (`@~project/memories/x.md`, `@docs/x.md`), where the
|
|
193
|
+
* first segment (up to `/` or `:`) names a registered alias,
|
|
194
|
+
* - or a recipe namespace path (`@~workflow/task-files/_partials/x.md`),
|
|
195
|
+
* where the `~` sigil names a namespace and the rest names a recipe and a
|
|
196
|
+
* file inside it. Namespaces are only consulted when a namespace resolver
|
|
197
|
+
* was supplied, and always after aliases.
|
|
151
198
|
*
|
|
152
199
|
* Lines inside fenced code blocks (``` or ~~~, per CommonMark) are left
|
|
153
200
|
* verbatim, so include syntax can be documented without being executed.
|
|
154
201
|
*
|
|
155
202
|
* Resolution produces an ordered candidate list (see include-resolver); the
|
|
156
203
|
* first candidate that exists on disk is used. If none exist, it errors,
|
|
157
|
-
* listing every path tried
|
|
204
|
+
* naming the including file and listing every path tried, plus what went
|
|
205
|
+
* wrong with the namespace lookup when one was attempted.
|
|
206
|
+
*
|
|
207
|
+
* @param content - The file's raw text.
|
|
208
|
+
* @param baseDir - Directory the relative candidate resolves against.
|
|
209
|
+
* @param projectRoot - Root used to render source comments as relative paths.
|
|
210
|
+
* @param fromFile - Absolute path of the file being processed; defaults to `baseDir`.
|
|
158
211
|
*/
|
|
159
|
-
private processIncludes(
|
|
160
|
-
|
|
161
|
-
|
|
212
|
+
private processIncludes(
|
|
213
|
+
content: string,
|
|
214
|
+
baseDir: string,
|
|
215
|
+
projectRoot: string,
|
|
216
|
+
fromFile?: string
|
|
217
|
+
): string {
|
|
218
|
+
// First segment allows ~ and . (so ./ and ../ work), then path chars;
|
|
219
|
+
// separators / and :; allows ${...}.
|
|
220
|
+
const includePattern = /^@([~a-zA-Z0-9_.${}][a-zA-Z0-9_\-/.:${}]*\.md)$/;
|
|
162
221
|
const fenceOpenPattern = /^ {0,3}(`{3,}|~{3,})/;
|
|
163
222
|
const fenceClosePattern = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
|
|
164
223
|
|
|
@@ -192,16 +251,23 @@ export class CompilationService {
|
|
|
192
251
|
}
|
|
193
252
|
|
|
194
253
|
const includePath = match[1].trim();
|
|
195
|
-
const candidates =
|
|
254
|
+
const { candidates, namespaceIssue } = resolveInclude(includePath, {
|
|
196
255
|
aliases: this.aliases,
|
|
197
256
|
scope: this.includeScope,
|
|
198
257
|
baseDir,
|
|
258
|
+
namespaceResolver: this.namespaceResolver,
|
|
259
|
+
fromFile: fromFile ?? baseDir,
|
|
199
260
|
});
|
|
200
261
|
const fullPath = candidates.find((c) => fs.existsSync(c));
|
|
201
262
|
|
|
202
263
|
if (!fullPath) {
|
|
264
|
+
const explanation = namespaceIssue
|
|
265
|
+
? `\n${formatNamespaceProblem(namespaceIssue)}`
|
|
266
|
+
: `\n in file: ${fromFile ?? baseDir}`;
|
|
203
267
|
this.handleError(
|
|
204
|
-
`Include not found: @${includePath}\n tried:\n${candidates
|
|
268
|
+
`Include not found: @${includePath}${explanation}\n tried:\n${candidates
|
|
269
|
+
.map((c) => ` - ${c}`)
|
|
270
|
+
.join("\n")}`
|
|
205
271
|
);
|
|
206
272
|
out.push("");
|
|
207
273
|
continue;
|
|
@@ -246,7 +312,7 @@ export class CompilationService {
|
|
|
246
312
|
try {
|
|
247
313
|
const content = fs.readFileSync(filePath, "utf8");
|
|
248
314
|
const baseDir = path.dirname(filePath);
|
|
249
|
-
const processedContent = this.processIncludes(content, baseDir, projectRoot);
|
|
315
|
+
const processedContent = this.processIncludes(content, baseDir, projectRoot, filePath);
|
|
250
316
|
this.visited.add(filePath);
|
|
251
317
|
return processedContent;
|
|
252
318
|
} catch (error) {
|
|
@@ -258,11 +324,25 @@ export class CompilationService {
|
|
|
258
324
|
}
|
|
259
325
|
}
|
|
260
326
|
|
|
261
|
-
/**
|
|
262
|
-
|
|
327
|
+
/**
|
|
328
|
+
* Render template content using LiquidJS with the given variable scope.
|
|
329
|
+
*
|
|
330
|
+
* @param content - The template text.
|
|
331
|
+
* @param vars - Variable scope handed to LiquidJS and to `${var}` path substitution.
|
|
332
|
+
* @param roots - Filesystem roots searched by `{% render %}`.
|
|
333
|
+
* @param fromFile - Absolute path of the template, so `~namespace` render paths are scoped correctly.
|
|
334
|
+
*/
|
|
335
|
+
private async renderContent(
|
|
336
|
+
content: string,
|
|
337
|
+
vars: Record<string, string>,
|
|
338
|
+
roots: string[],
|
|
339
|
+
fromFile?: string
|
|
340
|
+
): Promise<string> {
|
|
263
341
|
const engine = createLiquidEngine(roots, {
|
|
264
342
|
aliases: this.aliases,
|
|
265
343
|
scope: { ...this.includeScope, ...vars },
|
|
344
|
+
namespaceResolver: this.namespaceResolver,
|
|
345
|
+
fromFile,
|
|
266
346
|
});
|
|
267
347
|
try {
|
|
268
348
|
return await engine.parseAndRender(content, vars);
|
|
@@ -343,7 +423,7 @@ ${taskFileContents}
|
|
|
343
423
|
stateFileEntries: StateFileEntry[]
|
|
344
424
|
): Promise<boolean> {
|
|
345
425
|
subheading(path.basename(target.rootInputPath), "▷");
|
|
346
|
-
|
|
426
|
+
showVariable("Entry Point", target.rootInputPath);
|
|
347
427
|
|
|
348
428
|
this.initializeEncoder();
|
|
349
429
|
|
|
@@ -377,22 +457,10 @@ ${taskFileContents}
|
|
|
377
457
|
// Resolve destination path: prefer destinationFile, fall back to destinationDir mirroring
|
|
378
458
|
let destFile: string;
|
|
379
459
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const sourceRelative = target.globBase
|
|
385
|
-
? path.relative(target.globBase, target.rootInputPath)
|
|
386
|
-
: path.basename(target.rootInputPath);
|
|
387
|
-
|
|
388
|
-
// Strip .tpl. from the output filename (e.g. foo.tpl.md -> foo.md)
|
|
389
|
-
const outputRelative = sourceRelative.replace(/\.tpl\./, ".");
|
|
390
|
-
|
|
391
|
-
destFile = path.join(output.destinationDir, outputRelative);
|
|
392
|
-
} else {
|
|
393
|
-
// Neither set — skip
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
460
|
+
const resolvedDest = resolveOutputPath(target, output);
|
|
461
|
+
// Neither destinationFile nor destinationDir set — skip
|
|
462
|
+
if (resolvedDest === undefined) continue;
|
|
463
|
+
destFile = resolvedDest;
|
|
396
464
|
|
|
397
465
|
// Skip if content is unchanged and file already exists (unless --rebuild)
|
|
398
466
|
const existingEntry = state.files.find(f => f.dest === destFile);
|
|
@@ -424,11 +492,16 @@ ${taskFileContents}
|
|
|
424
492
|
}
|
|
425
493
|
|
|
426
494
|
const resolvedContent = (isTpl && output.vars)
|
|
427
|
-
? await this.renderContent(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
495
|
+
? await this.renderContent(
|
|
496
|
+
content,
|
|
497
|
+
{
|
|
498
|
+
...output.vars,
|
|
499
|
+
sousTemplatePath: target.rootInputPath,
|
|
500
|
+
sousTemplateDir: promptsRoot,
|
|
501
|
+
},
|
|
502
|
+
[promptsRoot],
|
|
503
|
+
target.rootInputPath
|
|
504
|
+
)
|
|
432
505
|
: content;
|
|
433
506
|
const fileContent = resolvedContent;
|
|
434
507
|
const outputDir = path.dirname(destFile);
|
|
@@ -557,8 +630,16 @@ ${taskFileContents}
|
|
|
557
630
|
subheading("Done.", "✓");
|
|
558
631
|
}
|
|
559
632
|
|
|
560
|
-
// Update state
|
|
561
|
-
|
|
633
|
+
// Update state: fresh entries for everything compiled this pass, plus
|
|
634
|
+
// carried-forward entries for outputs this pass did not touch — other
|
|
635
|
+
// targets' outputs during a partial rebuild, and outputs dropped from the
|
|
636
|
+
// config, which must stay tracked so prune/clear can still find them.
|
|
637
|
+
// Entries whose files are gone from disk are released.
|
|
638
|
+
const writtenDests = new Set(stateFileEntries.map((e) => e.dest));
|
|
639
|
+
state.files = [
|
|
640
|
+
...stateFileEntries,
|
|
641
|
+
...state.files.filter((f) => !writtenDests.has(f.dest) && fs.existsSync(f.dest)),
|
|
642
|
+
];
|
|
562
643
|
state.lastBuild = new Date().toISOString();
|
|
563
644
|
|
|
564
645
|
if (stateFilePath && !this.dryRun) {
|