@sanity/runtime-cli 17.8.0-next.g5972da4 → 17.9.0-blueprinit.g14aaebd

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.
Files changed (64) hide show
  1. package/README.md +21 -20
  2. package/dist/actions/blueprinit/manifest.d.ts +46 -0
  3. package/dist/actions/blueprinit/manifest.js +121 -0
  4. package/dist/actions/blueprinit/quote.d.ts +6 -0
  5. package/dist/actions/blueprinit/quote.js +8 -0
  6. package/dist/actions/blueprinit/studio.d.ts +6 -0
  7. package/dist/actions/blueprinit/studio.js +43 -0
  8. package/dist/actions/blueprinit/templates.d.ts +73 -0
  9. package/dist/actions/blueprinit/templates.js +138 -0
  10. package/dist/actions/blueprinit/versions.d.ts +8 -0
  11. package/dist/actions/blueprinit/versions.js +11 -0
  12. package/dist/actions/blueprinit/workspace.d.ts +27 -0
  13. package/dist/actions/blueprinit/workspace.js +145 -0
  14. package/dist/actions/blueprints/pre-deploy.js +2 -0
  15. package/dist/actions/blueprints/stacks.d.ts +1 -0
  16. package/dist/actions/sanity/create-project.d.ts +19 -0
  17. package/dist/actions/sanity/create-project.js +32 -0
  18. package/dist/actions/sanity/datasets.d.ts +15 -0
  19. package/dist/actions/sanity/datasets.js +26 -0
  20. package/dist/commands/functions/test.d.ts +1 -0
  21. package/dist/commands/functions/test.js +5 -0
  22. package/dist/commands/init.d.ts +21 -0
  23. package/dist/commands/init.js +82 -0
  24. package/dist/cores/blueprinit/adopt.d.ts +6 -0
  25. package/dist/cores/blueprinit/adopt.js +137 -0
  26. package/dist/cores/blueprinit/columns.d.ts +26 -0
  27. package/dist/cores/blueprinit/columns.js +27 -0
  28. package/dist/cores/blueprinit/finish.d.ts +15 -0
  29. package/dist/cores/blueprinit/finish.js +72 -0
  30. package/dist/cores/blueprinit/fresh.d.ts +3 -0
  31. package/dist/cores/blueprinit/fresh.js +219 -0
  32. package/dist/cores/blueprinit/naming.d.ts +27 -0
  33. package/dist/cores/blueprinit/naming.js +43 -0
  34. package/dist/cores/blueprinit/preview.d.ts +34 -0
  35. package/dist/cores/blueprinit/preview.js +110 -0
  36. package/dist/cores/blueprinit/project-choice.d.ts +28 -0
  37. package/dist/cores/blueprinit/project-choice.js +82 -0
  38. package/dist/cores/blueprinit/prompts.d.ts +13 -0
  39. package/dist/cores/blueprinit/prompts.js +42 -0
  40. package/dist/cores/blueprinit/results.d.ts +8 -0
  41. package/dist/cores/blueprinit/results.js +4 -0
  42. package/dist/cores/blueprinit/selection.d.ts +15 -0
  43. package/dist/cores/blueprinit/selection.js +90 -0
  44. package/dist/cores/blueprinit/stack-name.d.ts +39 -0
  45. package/dist/cores/blueprinit/stack-name.js +56 -0
  46. package/dist/cores/blueprinit/target.d.ts +33 -0
  47. package/dist/cores/blueprinit/target.js +80 -0
  48. package/dist/cores/blueprinit/types.d.ts +20 -0
  49. package/dist/cores/blueprinit/types.js +1 -0
  50. package/dist/cores/blueprinit.d.ts +7 -0
  51. package/dist/cores/blueprinit.js +64 -0
  52. package/dist/cores/functions/test.js +41 -26
  53. package/dist/utils/display/intro.d.ts +6 -0
  54. package/dist/utils/display/intro.js +63 -0
  55. package/dist/utils/functions/child-process-wrapper.js +3 -2
  56. package/dist/utils/functions/child-runtime/durable.d.ts +2 -2
  57. package/dist/utils/functions/child-runtime/durable.js +52 -2
  58. package/dist/utils/functions/durable-logs.d.ts +16 -0
  59. package/dist/utils/functions/durable-logs.js +52 -0
  60. package/dist/utils/functions/invoke/local.js +2 -1
  61. package/dist/utils/types.d.ts +18 -4
  62. package/oclif.manifest.json +159 -1
  63. package/package.json +1 -1
  64. package/skills/init.md +199 -0
@@ -0,0 +1,42 @@
1
+ import { confirm, input } from '@inquirer/prompts';
2
+ import { DEFAULT_STUDIO_NAME, slugify } from '../../actions/blueprinit/manifest.js';
3
+ /**
4
+ * Resource names must be unique within a Blueprint, so report every name used twice.
5
+ *
6
+ * The generated names cannot collide with each other, because each one carries its
7
+ * resource type. A dataset name or a `--studio-name` value can still land on one.
8
+ */
9
+ export function duplicateResourceNames(names) {
10
+ const seen = new Set();
11
+ const duplicates = new Set();
12
+ for (const name of names) {
13
+ if (seen.has(name))
14
+ duplicates.add(name);
15
+ else
16
+ seen.add(name);
17
+ }
18
+ return [...duplicates];
19
+ }
20
+ export async function confirmUnlessSkipped(flags, interactive, message) {
21
+ if (flags.yes || !interactive)
22
+ return true;
23
+ return await confirm({ message, default: true });
24
+ }
25
+ /** The new Stack's name, which is also the hosted Studio slug. */
26
+ export async function promptForStackName(flags, interactive, defaultName) {
27
+ if (flags.stackName)
28
+ return slugify(flags.stackName) || defaultName;
29
+ if (flags.yes || !interactive)
30
+ return defaultName;
31
+ const answer = await input({
32
+ message: 'Name your Stack and hosted Studio?',
33
+ default: defaultName,
34
+ });
35
+ return slugify(answer) || defaultName;
36
+ }
37
+ /** The Blueprint-local Studio name, which is also its directory under `studios/`. */
38
+ export function studioResourceName(flags) {
39
+ if (!flags.studioName)
40
+ return DEFAULT_STUDIO_NAME;
41
+ return slugify(flags.studioName) || DEFAULT_STUDIO_NAME;
42
+ }
@@ -0,0 +1,8 @@
1
+ import type { CoreResult } from '../index.js';
2
+ /** A failed step, in the shape every helper in this flow reports. */
3
+ export interface FlowError {
4
+ message: string;
5
+ suggestions?: string[];
6
+ }
7
+ /** Turn a failed step into the result the command returns. */
8
+ export declare function coreFailure(error: FlowError): CoreResult;
@@ -0,0 +1,4 @@
1
+ /** Turn a failed step into the result the command returns. */
2
+ export function coreFailure(error) {
3
+ return { success: false, error: error.message, suggestions: error.suggestions };
4
+ }
@@ -0,0 +1,15 @@
1
+ import type { Dataset } from '../../actions/sanity/datasets.js';
2
+ import type { GroupedProjects, Project } from '../../actions/sanity/projects.js';
3
+ import type { Result } from '../../utils/types.js';
4
+ interface SelectionError {
5
+ message: string;
6
+ suggestions?: string[];
7
+ }
8
+ export declare function chooseOrganization(groups: GroupedProjects[], organizationId: string | undefined, interactive: boolean): Promise<Result<GroupedProjects, SelectionError>>;
9
+ export declare function chooseProject(projects: Project[], projectId: string | undefined, organizationName: string, interactive: boolean): Promise<Result<Project, SelectionError>>;
10
+ export declare function chooseDataset(datasets: Dataset[], datasetName: string | undefined, projectName: string, interactive: boolean): Promise<Result<Dataset, SelectionError>>;
11
+ export declare function findProject(groups: GroupedProjects[], projectId: string): {
12
+ organization: GroupedProjects['organization'];
13
+ project: Project;
14
+ } | undefined;
15
+ export {};
@@ -0,0 +1,90 @@
1
+ import { select } from '@inquirer/prompts';
2
+ export async function chooseOrganization(groups, organizationId, interactive) {
3
+ if (groups.length === 0) {
4
+ return failure('No organizations found for this account.');
5
+ }
6
+ if (organizationId) {
7
+ const organization = groups.find((group) => group.organization.id === organizationId);
8
+ return organization
9
+ ? { ok: true, value: organization }
10
+ : failure(`No organization "${organizationId}" found for this account.`);
11
+ }
12
+ if (groups.length === 1)
13
+ return { ok: true, value: groups[0] };
14
+ if (!interactive) {
15
+ return failure('Multiple organizations found.', ['Pass --organization-id <id> to choose one.']);
16
+ }
17
+ const selectedId = await select({
18
+ message: 'Choose a Sanity organization:',
19
+ choices: groups.map(({ organization, projects }) => ({
20
+ name: organization.name,
21
+ value: organization.id,
22
+ description: `${projects?.length ?? 0} projects`,
23
+ })),
24
+ });
25
+ const selected = groups.find(({ organization }) => organization.id === selectedId);
26
+ return selected ? { ok: true, value: selected } : failure('Organization not found.');
27
+ }
28
+ export async function chooseProject(projects, projectId, organizationName, interactive) {
29
+ if (projects.length === 0) {
30
+ return failure(`No projects found in ${organizationName}.`);
31
+ }
32
+ if (projectId) {
33
+ const project = projects.find(({ id }) => id === projectId);
34
+ return project
35
+ ? { ok: true, value: project }
36
+ : failure(`No project "${projectId}" found in ${organizationName}.`);
37
+ }
38
+ if (projects.length === 1)
39
+ return { ok: true, value: projects[0] };
40
+ if (!interactive) {
41
+ return failure('Multiple projects found.', ['Pass --project-id <id> to choose one.']);
42
+ }
43
+ const selectedId = await select({
44
+ message: 'Choose a Sanity project:',
45
+ choices: projects.map((project) => ({ name: project.displayName, value: project.id })),
46
+ });
47
+ const selected = projects.find(({ id }) => id === selectedId);
48
+ return selected ? { ok: true, value: selected } : failure('Project not found.');
49
+ }
50
+ export async function chooseDataset(datasets, datasetName, projectName, interactive) {
51
+ if (datasets.length === 0) {
52
+ return failure(`No datasets found in ${projectName}.`);
53
+ }
54
+ if (datasetName) {
55
+ const dataset = datasets.find(({ name }) => name === datasetName);
56
+ return dataset
57
+ ? { ok: true, value: dataset }
58
+ : failure(`No dataset "${datasetName}" found in ${projectName}.`);
59
+ }
60
+ if (datasets.length === 1)
61
+ return { ok: true, value: datasets[0] };
62
+ if (!interactive) {
63
+ return failure('Multiple datasets found.', ['Pass --dataset <name> to choose one.']);
64
+ }
65
+ const production = datasets.find(({ name }) => name === 'production');
66
+ const orderedDatasets = production
67
+ ? [production, ...datasets.filter(({ name }) => name !== 'production')]
68
+ : datasets;
69
+ const selectedName = await select({
70
+ message: 'Choose a Sanity dataset:',
71
+ choices: orderedDatasets.map((dataset) => ({
72
+ name: dataset.name,
73
+ value: dataset.name,
74
+ description: dataset.aclMode,
75
+ })),
76
+ });
77
+ const selected = datasets.find(({ name }) => name === selectedName);
78
+ return selected ? { ok: true, value: selected } : failure('Dataset not found.');
79
+ }
80
+ export function findProject(groups, projectId) {
81
+ for (const { organization, projects = [] } of groups) {
82
+ const project = projects.find(({ id }) => id === projectId);
83
+ if (project)
84
+ return { organization, project };
85
+ }
86
+ return undefined;
87
+ }
88
+ function failure(message, suggestions) {
89
+ return { ok: false, error: { message, suggestions } };
90
+ }
@@ -0,0 +1,39 @@
1
+ import type { Logger } from '../../utils/logger.js';
2
+ import type { BlueprinitFlags } from './types.js';
3
+ interface TakenNamesOptions {
4
+ token: string;
5
+ organizationId: string;
6
+ log: Logger;
7
+ }
8
+ /**
9
+ * Stack names already used in the organization.
10
+ *
11
+ * Returns nothing when the listing fails. A token that cannot read Stacks is not a reason
12
+ * to stop, because the create call reports the same conflict a moment later.
13
+ */
14
+ export declare function takenStackNames(options: TakenNamesOptions): Promise<Set<string>>;
15
+ interface ChooseStackNameOptions {
16
+ flags: BlueprinitFlags;
17
+ interactive: boolean;
18
+ defaultName: string;
19
+ taken: Set<string>;
20
+ log: Logger;
21
+ }
22
+ type ChooseStackNameResult = {
23
+ ok: true;
24
+ value: string;
25
+ } | {
26
+ ok: false;
27
+ error: string;
28
+ suggestions: string[];
29
+ };
30
+ /**
31
+ * A Stack name the organization does not already hold.
32
+ *
33
+ * Asks again rather than failing, because the conflict is only visible after `init` has
34
+ * written every local file.
35
+ */
36
+ export declare function chooseStackName(options: ChooseStackNameOptions): Promise<ChooseStackNameResult>;
37
+ /** `field-notes-production`, then `field-notes-production-2`, until one is free. */
38
+ export declare function nextFreeName(name: string, taken: Set<string>): string;
39
+ export {};
@@ -0,0 +1,56 @@
1
+ import { listStacks } from '../../actions/blueprints/stacks.js';
2
+ import { warn } from '../../utils/display/presenters.js';
3
+ import { promptForStackName } from './prompts.js';
4
+ /**
5
+ * Stack names already used in the organization.
6
+ *
7
+ * Returns nothing when the listing fails. A token that cannot read Stacks is not a reason
8
+ * to stop, because the create call reports the same conflict a moment later.
9
+ */
10
+ export async function takenStackNames(options) {
11
+ const { token, organizationId, log } = options;
12
+ const result = await listStacks({ token, scopeType: 'organization', scopeId: organizationId }, log);
13
+ if (!result.ok) {
14
+ log.verbose(`Could not list Stacks to check the name: ${result.error}`);
15
+ return new Set();
16
+ }
17
+ return new Set(result.stacks.map(({ name }) => name));
18
+ }
19
+ /**
20
+ * A Stack name the organization does not already hold.
21
+ *
22
+ * Asks again rather than failing, because the conflict is only visible after `init` has
23
+ * written every local file.
24
+ */
25
+ export async function chooseStackName(options) {
26
+ const { flags, interactive, defaultName, taken, log } = options;
27
+ const suggested = nextFreeName(defaultName, taken);
28
+ if (suggested !== defaultName) {
29
+ log(warn(`A Stack named "${defaultName}" already exists. Suggesting "${suggested}".`));
30
+ }
31
+ let name = await promptForStackName(flags, interactive, suggested);
32
+ while (taken.has(name)) {
33
+ // Only a --stack-name value can be taken here; every other path starts from a free name.
34
+ if (!interactive || flags.yes || flags.stackName) {
35
+ return {
36
+ ok: false,
37
+ error: `A Stack named "${name}" already exists in this organization.`,
38
+ suggestions: [`Pass --stack-name with a different value, such as ${suggested}.`],
39
+ };
40
+ }
41
+ log(warn(`A Stack named "${name}" already exists in this organization.`));
42
+ name = await promptForStackName({}, interactive, nextFreeName(name, taken));
43
+ }
44
+ return { ok: true, value: name };
45
+ }
46
+ /** `field-notes-production`, then `field-notes-production-2`, until one is free. */
47
+ export function nextFreeName(name, taken) {
48
+ if (!taken.has(name))
49
+ return name;
50
+ for (let suffix = 2; suffix < 100; suffix += 1) {
51
+ const candidate = `${name}-${suffix}`;
52
+ if (!taken.has(candidate))
53
+ return candidate;
54
+ }
55
+ return name;
56
+ }
@@ -0,0 +1,33 @@
1
+ import type { Result } from '../../utils/types.js';
2
+ interface TargetError {
3
+ message: string;
4
+ suggestions?: string[];
5
+ }
6
+ export declare function isEmptyDirectory(dir: string): boolean;
7
+ /**
8
+ * What the directory prompt offers.
9
+ *
10
+ * The current directory when it is free, and a new directory named for the project when it
11
+ * is not, so accepting the default never writes a scaffold beside unrelated files.
12
+ */
13
+ export declare function defaultTargetPath(dir: string, baseName: string): string;
14
+ /**
15
+ * Where to scaffold, asked rather than assumed.
16
+ *
17
+ * An unattended run keeps the current directory. Relocating a scaffold with nobody watching
18
+ * would move files a script expects to find where it started.
19
+ */
20
+ export declare function promptForTargetDirectory(dir: string, baseName: string, interactive: boolean): Promise<string>;
21
+ /**
22
+ * Resolve the answer against the starting directory and refuse a target that is already
23
+ * something else: a file, a Blueprint, or a Studio that adoption should handle instead.
24
+ */
25
+ export declare function resolveTargetDirectory(dir: string, answer: string, bin: string): Result<string, TargetError>;
26
+ /**
27
+ * The `cd` the printed next steps need, or nothing when the files landed here.
28
+ *
29
+ * Measured from the process working directory, not from the flow's starting directory,
30
+ * because a directory argument moves the target without moving the reader.
31
+ */
32
+ export declare function relativeTargetPath(target: string, from?: string): string | undefined;
33
+ export {};
@@ -0,0 +1,80 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
+ import { cwd } from 'node:process';
4
+ import { input } from '@inquirer/prompts';
5
+ import { detectStudio } from '../../actions/blueprinit/studio.js';
6
+ import { findBlueprintFile } from '../../actions/blueprints/blueprint.js';
7
+ /** Entries that say nothing about whether a directory is in use. */
8
+ const IGNORED_ENTRIES = new Set(['.git', '.DS_Store']);
9
+ export function isEmptyDirectory(dir) {
10
+ try {
11
+ return readdirSync(dir).every((entry) => IGNORED_ENTRIES.has(entry));
12
+ }
13
+ catch {
14
+ // A directory that does not exist yet has nothing to overwrite.
15
+ return true;
16
+ }
17
+ }
18
+ /**
19
+ * What the directory prompt offers.
20
+ *
21
+ * The current directory when it is free, and a new directory named for the project when it
22
+ * is not, so accepting the default never writes a scaffold beside unrelated files.
23
+ */
24
+ export function defaultTargetPath(dir, baseName) {
25
+ return isEmptyDirectory(dir) ? '.' : `./${baseName}`;
26
+ }
27
+ /**
28
+ * Where to scaffold, asked rather than assumed.
29
+ *
30
+ * An unattended run keeps the current directory. Relocating a scaffold with nobody watching
31
+ * would move files a script expects to find where it started.
32
+ */
33
+ export async function promptForTargetDirectory(dir, baseName, interactive) {
34
+ if (!interactive)
35
+ return '.';
36
+ const fallback = defaultTargetPath(dir, baseName);
37
+ const answer = await input({ message: 'Where should the files go?', default: fallback });
38
+ return answer.trim() || fallback;
39
+ }
40
+ /**
41
+ * Resolve the answer against the starting directory and refuse a target that is already
42
+ * something else: a file, a Blueprint, or a Studio that adoption should handle instead.
43
+ */
44
+ export function resolveTargetDirectory(dir, answer, bin) {
45
+ const target = isAbsolute(answer) ? answer : resolve(dir, answer);
46
+ if (existsSync(target) && !statSync(target).isDirectory()) {
47
+ return { ok: false, error: { message: `${answer} is not a directory.` } };
48
+ }
49
+ const blueprint = findBlueprintFile(target);
50
+ if (blueprint) {
51
+ return {
52
+ ok: false,
53
+ error: {
54
+ message: `A Blueprint already exists in ${answer} (${blueprint.fileName}).`,
55
+ suggestions: [`Review it with: npx ${bin} blueprints doctor`],
56
+ },
57
+ };
58
+ }
59
+ const studio = detectStudio(target);
60
+ if (studio) {
61
+ return {
62
+ ok: false,
63
+ error: {
64
+ message: `${answer} already holds a Studio (${studio.configFile}).`,
65
+ suggestions: [`Wrap it in a Blueprint with: npx ${bin} init ${answer}`],
66
+ },
67
+ };
68
+ }
69
+ return { ok: true, value: target };
70
+ }
71
+ /**
72
+ * The `cd` the printed next steps need, or nothing when the files landed here.
73
+ *
74
+ * Measured from the process working directory, not from the flow's starting directory,
75
+ * because a directory argument moves the target without moving the reader.
76
+ */
77
+ export function relativeTargetPath(target, from = cwd()) {
78
+ const path = relative(from, target);
79
+ return path === '' ? undefined : path;
80
+ }
@@ -0,0 +1,20 @@
1
+ import type { Logger } from '../../utils/logger.js';
2
+ export interface BlueprinitFlags {
3
+ yes?: boolean;
4
+ organizationId?: string;
5
+ projectId?: string;
6
+ /** Display name for a project to create, instead of using one that exists. */
7
+ createProject?: string;
8
+ datasetName?: string;
9
+ stackName?: string;
10
+ studioName?: string;
11
+ }
12
+ export interface BlueprinitFlowOptions {
13
+ bin: string;
14
+ dir: string;
15
+ /** True when a directory argument named the target, so the flow must not ask for one. */
16
+ targetProvided: boolean;
17
+ flags: BlueprinitFlags;
18
+ log: Logger;
19
+ token: string;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { BlueprinitFlags } from './blueprinit/types.js';
2
+ import type { CoreConfig, CoreResult } from './index.js';
3
+ export interface BlueprinitOptions extends CoreConfig {
4
+ dir?: string;
5
+ flags?: BlueprinitFlags;
6
+ }
7
+ export declare function blueprinitCore(options: BlueprinitOptions): Promise<CoreResult>;
@@ -0,0 +1,64 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { cwd } from 'node:process';
3
+ import { detectStudio } from '../actions/blueprinit/studio.js';
4
+ import { findBlueprintFile } from '../actions/blueprints/blueprint.js';
5
+ import { readConfigFile } from '../actions/blueprints/config.js';
6
+ import { validTokenOrErrorMessage } from '../utils/validated-token.js';
7
+ import { adoptFlow } from './blueprinit/adopt.js';
8
+ import { freshFlow } from './blueprinit/fresh.js';
9
+ export async function blueprinitCore(options) {
10
+ const { bin, log, flags = {}, dir = cwd() } = options;
11
+ try {
12
+ // findBlueprintFile answers with any file it is handed, so refuse a file target first.
13
+ if (existsSync(dir) && !statSync(dir).isDirectory()) {
14
+ return {
15
+ success: false,
16
+ error: `${dir} is not a directory.`,
17
+ suggestions: ['Pass a directory to initialize, or no argument to use this one.'],
18
+ };
19
+ }
20
+ const existingBlueprint = findBlueprintFile(dir);
21
+ if (existingBlueprint) {
22
+ const blueprintConfig = readConfigFile(existingBlueprint.blueprintFilePath);
23
+ if (!blueprintConfig?.stackId) {
24
+ return {
25
+ success: false,
26
+ error: `A Blueprint exists here (${existingBlueprint.fileName}), but it is not connected to a Stack.`,
27
+ suggestions: [`Finish setup with: npx ${bin} blueprints init`],
28
+ };
29
+ }
30
+ log(`A blueprint already exists here (${existingBlueprint.fileName}).`);
31
+ log('');
32
+ log('Use these instead:');
33
+ log(` npx ${bin} blueprints config --edit`);
34
+ log(` npx ${bin} blueprints doctor`);
35
+ return {
36
+ success: true,
37
+ json: { alreadyInitialized: true, blueprintFile: existingBlueprint.fileName },
38
+ };
39
+ }
40
+ const tokenCheck = await validTokenOrErrorMessage(log);
41
+ if (!tokenCheck.ok) {
42
+ return {
43
+ success: false,
44
+ error: 'No Sanity session found.',
45
+ suggestions: ['Sign in first: npx sanity login', 'Then run init again.'],
46
+ };
47
+ }
48
+ log('Setting up your Sanity workspace.');
49
+ log('');
50
+ const flowOptions = {
51
+ bin,
52
+ dir,
53
+ targetProvided: Boolean(options.dir),
54
+ flags,
55
+ log,
56
+ token: tokenCheck.value,
57
+ };
58
+ const studio = detectStudio(dir);
59
+ return studio ? await adoptFlow({ ...flowOptions, studio }) : await freshFlow(flowOptions);
60
+ }
61
+ catch (error) {
62
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
63
+ }
64
+ }
@@ -3,6 +3,7 @@ import { testAction } from '../../actions/functions/test.js';
3
3
  import config from '../../config.js';
4
4
  import { STEP_PHASE_GLYPH } from '../../constants.js';
5
5
  import { resolveDeployedResources } from '../../utils/blueprints/resolve-deployed-resources.js';
6
+ import { createDurableJsonCollector } from '../../utils/functions/durable-logs.js';
6
7
  import { findFunctionInResources, getFunctionNames } from '../../utils/functions/find.js';
7
8
  import { runFunction } from '../../utils/functions/invoke/nested.js';
8
9
  import { buildContext, buildInvokeOptions, collectInvokeInput, defaultInvocationDeps, resolveInvocationPlan, } from '../../utils/functions/invoke/resolve-invoke-type.js';
@@ -12,19 +13,25 @@ import { isEventType, } from '../../utils/types.js';
12
13
  * @param events
13
14
  */
14
15
  function renderStepTimeline(events) {
15
- return events
16
- .filter((event) => event.phase !== 'start')
17
- .map((event) => {
16
+ const lines = [];
17
+ for (const event of events) {
18
+ if (event.kind === 'log') {
19
+ lines.push(`${event.timestamp} ${event.level.toUpperCase()} ${event.message}`);
20
+ continue;
21
+ }
22
+ if (event.phase === 'start')
23
+ continue;
18
24
  const glyph = STEP_PHASE_GLYPH[event.phase] ?? '•';
19
25
  const duration = typeof event.durationMs === 'number' ? ` (${Math.round(event.durationMs)}ms)` : '';
20
26
  const detail = event.phase === 'error' && event.error ? ` ${event.error.message}` : '';
21
- return ` ${glyph} ${event.name} [${event.kind}]${duration}${detail}`;
22
- });
27
+ lines.push(` ${glyph} ${event.name} [${event.kind}]${duration}${detail}`);
28
+ }
29
+ return lines;
23
30
  }
24
31
  export async function functionTestCore(options) {
25
32
  const { blueprint, log, error, args, flags, helpText } = options;
26
33
  const { name: fnName } = args;
27
- const { event, timeout, api, 'with-user-token': withUserToken } = flags;
34
+ const { event, timeout, api, 'with-user-token': withUserToken, 'no-wait': noWait } = flags;
28
35
  const { 'project-id': projectId = blueprint?.projectId, 'organization-id': organizationId = blueprint?.organizationId, } = flags;
29
36
  if (!fnName) {
30
37
  const functionNames = getFunctionNames(blueprint.resources);
@@ -77,10 +84,17 @@ export async function functionTestCore(options) {
77
84
  // Records each nested context.invoke() so they can be surfaced in order after the spinner resolves.
78
85
  const nestedRuns = [];
79
86
  let nestedOrder = 0;
80
- const stepEvents = [];
87
+ const durableJson = createDurableJsonCollector();
81
88
  const onStepEvent = plan.isDurable
82
89
  ? (event) => {
83
- stepEvents.push(event);
90
+ if (flags.json) {
91
+ durableJson.record(event);
92
+ return;
93
+ }
94
+ for (const line of renderStepTimeline([event])) {
95
+ if (line !== null)
96
+ log(line);
97
+ }
84
98
  }
85
99
  : undefined;
86
100
  /**
@@ -120,6 +134,7 @@ export async function functionTestCore(options) {
120
134
  depth: 0,
121
135
  callStack: [fnName],
122
136
  onStepEvent,
137
+ noWait,
123
138
  });
124
139
  if (error) {
125
140
  spinner.fail('Function execution failed.');
@@ -131,12 +146,6 @@ export async function functionTestCore(options) {
131
146
  logNestedRuns();
132
147
  const errorMessage = error.stack || error.message || error.name;
133
148
  const isTimeout = error.message?.includes('Timeout');
134
- if (plan.isDurable && stepEvents.length > 0) {
135
- log('Steps:');
136
- for (const line of renderStepTimeline(stepEvents)) {
137
- log(line);
138
- }
139
- }
140
149
  return {
141
150
  success: false,
142
151
  error: errorMessage,
@@ -147,20 +156,26 @@ export async function functionTestCore(options) {
147
156
  };
148
157
  }
149
158
  spinner.succeed('Function execution succeeded.');
150
- log('Logs:');
151
- log(logs || '');
152
- logNestedRuns();
153
- if (response) {
154
- log('Response:');
155
- log(JSON.stringify(response, null, 2));
156
- }
157
- if (plan.isDurable && stepEvents.length > 0) {
158
- log('Steps:');
159
- for (const line of renderStepTimeline(stepEvents)) {
160
- log(line);
159
+ if (!plan.isDurable) {
160
+ log('Logs:');
161
+ log(logs || '');
162
+ logNestedRuns();
163
+ if (response) {
164
+ log('Response:');
165
+ log(JSON.stringify(response, null, 2));
161
166
  }
162
167
  }
163
- return { success: true, json: { response, logs } };
168
+ const durableResult = durableJson.result();
169
+ return {
170
+ success: true,
171
+ json: plan.isDurable
172
+ ? {
173
+ response: response ?? null,
174
+ steps: durableResult.steps,
175
+ logs: durableResult.logs,
176
+ }
177
+ : { response, logs },
178
+ };
164
179
  }
165
180
  catch (error) {
166
181
  return {
@@ -0,0 +1,6 @@
1
+ /** A left-to-right reveal of the wordmark; the last frame is the finished line. */
2
+ export declare function introFrames(): string[];
3
+ /** Print the intro, animated when the terminal can show it and static when it cannot. */
4
+ export declare function playIntro({ json }?: {
5
+ json?: boolean;
6
+ }): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { env, stdout } from 'node:process';
2
+ import { onExit } from 'signal-exit';
3
+ import { isInteractive } from '../interactive.js';
4
+ import { styleText } from '../style-text.js';
5
+ const INDENT = ' ';
6
+ const WORDMARK = 'sanity';
7
+ const TAIL = ' with blueprints';
8
+ /** The leading edge of the reveal, densest first. */
9
+ const GRADIENT = ['▓', '▒', '░'];
10
+ const REVEAL_PER_FRAME = 1;
11
+ const FRAME_MS = 45;
12
+ /** Time the finished wordmark holds before the command's own output follows. */
13
+ const HOLD_MS = 250;
14
+ const HIDE_CURSOR = '\x1b[?25l';
15
+ const SHOW_CURSOR = '\x1b[?25h';
16
+ const CLEAR_LINE = '\x1b[2K';
17
+ function style(revealed, edge) {
18
+ const wordmark = revealed.slice(0, WORDMARK.length);
19
+ const tail = revealed.slice(WORDMARK.length);
20
+ return INDENT + styleText(['bold', 'red'], wordmark) + styleText('dim', tail + edge);
21
+ }
22
+ /** A left-to-right reveal of the wordmark; the last frame is the finished line. */
23
+ export function introFrames() {
24
+ const full = WORDMARK + TAIL;
25
+ const frames = [];
26
+ for (let revealed = REVEAL_PER_FRAME; revealed < full.length; revealed += REVEAL_PER_FRAME) {
27
+ const edge = GRADIENT.slice(0, Math.min(GRADIENT.length, full.length - revealed)).join('');
28
+ frames.push(style(full.slice(0, revealed), edge));
29
+ }
30
+ frames.push(style(full, ''));
31
+ return frames;
32
+ }
33
+ function animates() {
34
+ return isInteractive && env.TERM !== 'dumb' && !env.CI;
35
+ }
36
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37
+ /** Print the intro, animated when the terminal can show it and static when it cannot. */
38
+ export async function playIntro({ json = false } = {}) {
39
+ if (json)
40
+ return;
41
+ const frames = introFrames();
42
+ const finished = frames.at(-1) ?? '';
43
+ if (!animates()) {
44
+ stdout.write(`\n${finished}\n\n`);
45
+ return;
46
+ }
47
+ stdout.write(`\n${HIDE_CURSOR}`);
48
+ const cancelExitHook = onExit(() => {
49
+ stdout.write(SHOW_CURSOR);
50
+ });
51
+ try {
52
+ for (const frame of frames) {
53
+ stdout.write(`\r${CLEAR_LINE}${frame}`);
54
+ await sleep(FRAME_MS);
55
+ }
56
+ await sleep(HOLD_MS);
57
+ stdout.write('\n\n');
58
+ }
59
+ finally {
60
+ stdout.write(SHOW_CURSOR);
61
+ cancelExitHook();
62
+ }
63
+ }