project-auto-wizard 0.13.3 → 0.14.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 +1 -1
- package/bin/project-auto-wizard.js +18 -4
- package/package.json +2 -2
- package/payload/config/breaking-changes.json +28 -14
- package/payload/config/wizard-prompts.yml +102 -46
- package/payload/flutter-app/android/fastlane/Fastfile.playstore +44 -32
- package/payload/flutter-app/ios/ExportOptions.plist +8 -8
- package/payload/flutter-app/ios/fastlane/Fastfile +57 -43
- package/payload/scripts/changelog_manager.py +296 -257
- package/payload/scripts/issue_helper.py +54 -50
- package/payload/scripts/messages.py +2279 -0
- package/payload/scripts/truncate_release_notes.py +7 -6
- package/payload/scripts/version_manager.py +64 -61
- package/payload/version.yml.template +2 -1
- package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +17 -12
- package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +59 -34
- package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +17 -15
- package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +17 -10
- package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +54 -44
- package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +24 -17
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +194 -184
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +226 -213
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +46 -42
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +334 -241
- package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +152 -128
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +246 -188
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +353 -238
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +96 -85
- package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -36
- package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +945 -508
- package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +155 -133
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +76 -60
- package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +97 -77
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +62 -47
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +942 -505
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +148 -126
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +86 -70
- package/payload/workflows/react/PROJECT-REACT-CICD.yaml +112 -92
- package/payload/workflows/spring/PROJECT-SPRING-CI.yml +112 -82
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +180 -150
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +161 -131
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +970 -549
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +193 -162
- package/src/cli/args.js +98 -52
- package/src/cli/help.js +8 -41
- package/src/commands/doctor.js +85 -89
- package/src/commands/dry-run.js +63 -43
- package/src/commands/full.js +79 -77
- package/src/commands/install-settings.js +16 -15
- package/src/commands/interactive-flutter.js +20 -19
- package/src/commands/interactive.js +135 -132
- package/src/commands/purge.js +47 -42
- package/src/commands/status.js +36 -36
- package/src/commands/uninstall.js +43 -41
- package/src/context.js +12 -11
- package/src/core/assets.js +12 -11
- package/src/core/baseline.js +27 -27
- package/src/core/branches.js +29 -27
- package/src/core/branding.js +7 -6
- package/src/core/breaking-check.js +22 -21
- package/src/core/breaking.js +15 -8
- package/src/core/copy/app-files.js +5 -4
- package/src/core/copy/flutter-app.js +8 -8
- package/src/core/copy/gitignore.js +58 -50
- package/src/core/copy/readme.js +45 -35
- package/src/core/copy/simple.js +16 -15
- package/src/core/copy/workflows.js +131 -124
- package/src/core/deploy-style.js +51 -48
- package/src/core/detect-fs.js +57 -57
- package/src/core/detect.js +67 -65
- package/src/core/errors.js +2 -2
- package/src/core/flutter-doctor.js +17 -16
- package/src/core/flutter-hooks.js +33 -32
- package/src/core/flutter-options.js +34 -33
- package/src/core/fsutil.js +12 -12
- package/src/core/installed-stores.js +4 -4
- package/src/core/logger.js +42 -40
- package/src/core/paths-resolve.js +88 -86
- package/src/core/paths.js +10 -10
- package/src/core/release-options.js +8 -8
- package/src/core/removal-exec.js +14 -13
- package/src/core/removal-plan.js +37 -37
- package/src/core/types.js +47 -47
- package/src/core/verify.js +44 -38
- package/src/core/version-yml.js +76 -68
- package/src/core/wizard-env.js +46 -46
- package/src/core/wizard-labels.js +63 -41
- package/src/i18n/catalog/en/cli.js +50 -0
- package/src/i18n/catalog/en/commands.js +187 -0
- package/src/i18n/catalog/en/copy.js +19 -0
- package/src/i18n/catalog/en/core.js +74 -0
- package/src/i18n/catalog/en/core2.js +15 -0
- package/src/i18n/catalog/en/ui.js +221 -0
- package/src/i18n/catalog/en.js +12 -0
- package/src/i18n/catalog/index.js +5 -0
- package/src/i18n/catalog/ko/cli.js +50 -0
- package/src/i18n/catalog/ko/commands.js +187 -0
- package/src/i18n/catalog/ko/copy.js +19 -0
- package/src/i18n/catalog/ko/core.js +74 -0
- package/src/i18n/catalog/ko/core2.js +15 -0
- package/src/i18n/catalog/ko/ui.js +221 -0
- package/src/i18n/catalog/ko.js +10 -0
- package/src/i18n/index.js +44 -0
- package/src/i18n/languages.js +9 -0
- package/src/index.js +128 -94
- package/src/ui/ansi.js +7 -7
- package/src/ui/banner.js +7 -6
- package/src/ui/env-plan.js +82 -81
- package/src/ui/prompts.js +97 -122
- package/src/ui/readline-engine.js +59 -58
- package/src/ui/status-cards.js +37 -31
- package/src/ui/summary.js +89 -88
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// Flutter
|
|
2
|
-
//
|
|
1
|
+
// Flutter options: single source of truth for the env-var mode, store deploy targets and deploy modes.
|
|
2
|
+
// Store deploy targets follow the same structure as deploy-style.js (deploy style): value list, file filter, deselection cleanup.
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
5
5
|
import { sha256 } from "./baseline.js";
|
|
6
|
+
import { t } from "../i18n/index.js";
|
|
6
7
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
8
|
+
// Env-var injection mode. dart-define uses --dart-define-from-file; dotenv generates the .env for flutter_dotenv/envied.
|
|
9
|
+
// No "both" option that uses the two at once: it would mean managing env vars in two places.
|
|
9
10
|
export const ENV_MODES = ["dart-define", "dotenv"];
|
|
10
|
-
export const DEFAULT_ENV_MODE = "dart-define"; //
|
|
11
|
-
export const LEGACY_ENV_MODE = "dotenv"; //
|
|
11
|
+
export const DEFAULT_ENV_MODE = "dart-define"; // default for new installs
|
|
12
|
+
export const LEGACY_ENV_MODE = "dotenv"; // preserved so an existing install (has version.yml, no saved value) does not break silently
|
|
12
13
|
|
|
13
14
|
export const STORE_PLATFORMS = ["android", "ios"];
|
|
14
15
|
export const DEPLOY_MODES = ["store_only", "store_prepare", "store_submit"];
|
|
@@ -18,8 +19,8 @@ export const NO_STORE = "none";
|
|
|
18
19
|
export const isEnvMode = (v) => ENV_MODES.includes(v);
|
|
19
20
|
export const isDeployMode = (v) => DEPLOY_MODES.includes(v);
|
|
20
21
|
|
|
21
|
-
// "android,ios" | "android" | "none" | ""
|
|
22
|
-
//
|
|
22
|
+
// "android,ios" | "android" | "none" | "" to an array. Always in STORE_PLATFORMS order, so serialization is deterministic.
|
|
23
|
+
// null when any token is unknown or the input is not a string; callers treat that as "no value / invalid value".
|
|
23
24
|
export function parseStoreList(csv) {
|
|
24
25
|
if (typeof csv !== "string") return null;
|
|
25
26
|
const tokens = csv.split(",").map((t) => t.trim()).filter((t) => t !== "");
|
|
@@ -28,14 +29,14 @@ export function parseStoreList(csv) {
|
|
|
28
29
|
return STORE_PLATFORMS.filter((p) => tokens.includes(p));
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
//
|
|
32
|
+
// Serialization for saving: an empty array is written as "none" rather than an empty string, to tell "nothing selected" from "no value".
|
|
32
33
|
export function formatStoreList(stores) {
|
|
33
34
|
const ordered = STORE_PLATFORMS.filter((p) => stores.includes(p));
|
|
34
35
|
return ordered.length ? ordered.join(",") : NO_STORE;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
//
|
|
38
|
-
// FIREBASE
|
|
38
|
+
// Store workflows per platform (file names relative to payload/workflows/flutter/).
|
|
39
|
+
// FIREBASE, SELFHOSTED, TEST-APK, APP-BUILD-TRIGGER and CI are unrelated to the stores and are not listed here: they are always installed.
|
|
39
40
|
export const STORE_WORKFLOWS = {
|
|
40
41
|
android: ["PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml"],
|
|
41
42
|
ios: ["PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml", "PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml"],
|
|
@@ -43,20 +44,20 @@ export const STORE_WORKFLOWS = {
|
|
|
43
44
|
|
|
44
45
|
export const isStoreWorkflow = (filename) => Object.values(STORE_WORKFLOWS).flat().includes(filename);
|
|
45
46
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
47
|
+
// File filter: null stores means undecided, which keeps current behavior (install everything).
|
|
48
|
+
// With an array, store workflows pass only for the selected platforms and all other files always pass.
|
|
48
49
|
export function storeWorkflowFilter(stores) {
|
|
49
50
|
if (stores === null || stores === undefined) return () => true;
|
|
50
51
|
const allowed = new Set(STORE_PLATFORMS.filter((p) => stores.includes(p)).flatMap((p) => STORE_WORKFLOWS[p]));
|
|
51
52
|
return (filename) => !isStoreWorkflow(filename) || allowed.has(filename);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// dryRun
|
|
59
|
-
//
|
|
55
|
+
// Cleanup of deselected store workflows: same rule as cleanupOtherDeployWorkflows (deploy-style.js).
|
|
56
|
+
// untouched (same as the baseline installed hash) -> deleted
|
|
57
|
+
// modified -> moved to .bak (content kept, only the trigger is killed)
|
|
58
|
+
// User-owned Fastfile and ExportOptions.plist are not handled here (workflow files only).
|
|
59
|
+
// With dryRun only the decision is made and no files are touched (for the --dry-run preview).
|
|
60
|
+
// Returns: { removed:[], backedUp:[] }
|
|
60
61
|
export function cleanupDeselectedStoreWorkflows(workflowsDir, installedFilenames, stores, baseline, { dryRun = false } = {}) {
|
|
61
62
|
const keep = storeWorkflowFilter(stores);
|
|
62
63
|
const removed = [];
|
|
@@ -80,13 +81,13 @@ export function cleanupDeselectedStoreWorkflows(workflowsDir, installedFilenames
|
|
|
80
81
|
return { removed, backedUp };
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
//
|
|
84
|
+
// Store app files (paths relative to payload/flutter-app/), grouped per platform.
|
|
84
85
|
export const STORE_APP_FILES = {
|
|
85
86
|
android: ["android/fastlane/Fastfile.playstore"],
|
|
86
87
|
ios: ["ios/fastlane/Fastfile", "ios/ExportOptions.plist"],
|
|
87
88
|
};
|
|
88
89
|
|
|
89
|
-
//
|
|
90
|
+
// App file list for the selected platforms. All of them when stores is null (current behavior = install both).
|
|
90
91
|
export function storeAppFilesFor(stores) {
|
|
91
92
|
const platforms = stores === null || stores === undefined
|
|
92
93
|
? STORE_PLATFORMS
|
|
@@ -94,24 +95,24 @@ export function storeAppFilesFor(stores) {
|
|
|
94
95
|
return platforms.flatMap((p) => STORE_APP_FILES[p]);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
// store_submit
|
|
98
|
+
// store_submit submits a review on every main push, so a one-line notice is shown right after it is chosen. Other modes have nothing to announce, hence an empty string.
|
|
98
99
|
export function deployModeWarning(mode) {
|
|
99
|
-
return mode === "store_submit" ? "
|
|
100
|
+
return mode === "store_submit" ? t("core.flutterOptions.storeSubmitWarning") : "";
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
const validOr = (isValid, value, fallback) => (isValid(value) ? value : fallback);
|
|
103
104
|
|
|
104
|
-
//
|
|
105
|
-
// cli { envMode:"", stores:null|string[], androidDeployMode:"", iosDeployMode:"" } (
|
|
106
|
-
// existing parseExisting()
|
|
105
|
+
// Final option decision. Priority: CLI > value saved in version.yml > default.
|
|
106
|
+
// cli { envMode:"", stores:null|string[], androidDeployMode:"", iosDeployMode:"" } (empty/null = not given)
|
|
107
|
+
// existing parseExisting() result or null (no version.yml means a fresh install)
|
|
107
108
|
//
|
|
108
|
-
// - envMode
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// - stores: null
|
|
113
|
-
// -
|
|
114
|
-
// (`|| 'store_only'`
|
|
109
|
+
// - envMode default: fresh installs and existing installs that are "newly adding Flutter" get dart-define; projects
|
|
110
|
+
// that already had Flutter installed get dotenv. This keeps a single update from silently breaking a flutter_dotenv
|
|
111
|
+
// project, so the criterion is "did this project already have Flutter" (not merely whether existing is present:
|
|
112
|
+
// adding the flutter type for the first time to a Spring-only project must not be lumped into dotenv).
|
|
113
|
+
// - stores: null means "undecided": non-interactive keeps current behavior (install both), interactive asks.
|
|
114
|
+
// - Saved values are used only when valid. version.yml can be hand-edited and the value goes straight into a
|
|
115
|
+
// workflow expression (the `|| 'store_only'` fallback slot), so strings outside the list must be filtered out.
|
|
115
116
|
export function resolveFlutterOptions({ cli = {}, existing = null } = {}) {
|
|
116
117
|
const saved = existing?.options ?? {};
|
|
117
118
|
const hadFlutterAlready = Array.isArray(existing?.types) && existing.types.includes("flutter");
|
package/src/core/fsutil.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Shared filesystem utilities (byte-for-byte copy, LF preserved). Text is copied as-is so original line endings survive.
|
|
2
2
|
import {
|
|
3
3
|
cpSync, existsSync, readFileSync, writeFileSync, mkdirSync,
|
|
4
4
|
readdirSync, rmSync, accessSync, constants,
|
|
@@ -13,27 +13,27 @@ export function writeText(p, s) {
|
|
|
13
13
|
writeFileSync(p, s);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// Copy a single file (creates parent directories, bytes unchanged)
|
|
17
17
|
export function copyFileSync(src, dst) {
|
|
18
18
|
mkdirSync(dirname(dst), { recursive: true });
|
|
19
19
|
cpSync(src, dst);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
//
|
|
22
|
+
// Recursively copy a directory (contents go under dst)
|
|
23
23
|
export function copyDirSync(src, dst) {
|
|
24
24
|
mkdirSync(dst, { recursive: true });
|
|
25
25
|
cpSync(src, dst, { recursive: true });
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
//
|
|
28
|
+
// Delete a file/folder (harmless if missing)
|
|
29
29
|
export function remove(p) {
|
|
30
30
|
rmSync(p, { recursive: true, force: true });
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// (
|
|
36
|
-
//
|
|
33
|
+
// Names of the .yaml/.yml files directly inside a directory (subfolders excluded).
|
|
34
|
+
// Sort order is fixed: grouped by extension first (.yaml before .yml), alphabetical within each group.
|
|
35
|
+
// (A plain .sort() mixes extensions, so the traversal order diverges from existing installs and
|
|
36
|
+
// the key order of the version.yml deploy block changes with it. Grouping keeps re-runs byte-identical.)
|
|
37
37
|
export function listYamlFiles(dir) {
|
|
38
38
|
if (!existsSync(dir)) return [];
|
|
39
39
|
const names = readdirSync(dir, { withFileTypes: true })
|
|
@@ -44,10 +44,10 @@ export function listYamlFiles(dir) {
|
|
|
44
44
|
return [...yaml, ...yml];
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// dirs:
|
|
50
|
-
//
|
|
47
|
+
// Check permissions before writing - stopping midway on EACCES leaves a partial install, and the
|
|
48
|
+
// next run has no baseline, so every file already written would be classified as a conflict.
|
|
49
|
+
// dirs: folders to create files in (if missing, the nearest existing ancestor is checked), files: existing files to be overwritten.
|
|
50
|
+
// Returns the list of unwritable paths (relative to root).
|
|
51
51
|
export function findUnwritable(root, dirs = [], files = []) {
|
|
52
52
|
const writable = (p) => { try { accessSync(p, constants.W_OK); return true; } catch { return false; } };
|
|
53
53
|
const blocked = new Set();
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
// version.yml
|
|
3
|
-
//
|
|
1
|
+
// Infers platforms from the file names of already-installed store deploy workflows.
|
|
2
|
+
// Targets existing installs whose version.yml has no flutter_store value: treating a missing value as "nothing
|
|
3
|
+
// selected" would make the deselection cleanup rule delete store workflows that were working fine.
|
|
4
4
|
import { existsSync, readdirSync } from "node:fs";
|
|
5
5
|
import { STORE_PLATFORMS, STORE_WORKFLOWS } from "./flutter-options.js";
|
|
6
6
|
|
|
7
|
-
//
|
|
7
|
+
// Returns the platforms with at least one store workflow installed (in STORE_PLATFORMS order).
|
|
8
8
|
export function inferInstalledStores(workflowsDir) {
|
|
9
9
|
if (!existsSync(workflowsDir)) return [];
|
|
10
10
|
const installed = new Set(readdirSync(workflowsDir));
|
package/src/core/logger.js
CHANGED
|
@@ -1,45 +1,46 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Execution trace log - records "what was done, in what order, and why" chronologically.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Why append immediately: the moment you most want to see when debugging is right before a crash.
|
|
4
|
+
// A structure that writes everything at the end leaves nothing if an exception is thrown (the old install-log.js did).
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// Why local-only: so the level of detail is not constrained. A .gitignore placed inside the log
|
|
7
|
+
// directory takes only that folder out of tracking - the root .gitignore is left alone.
|
|
8
8
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
|
+
import { t } from "../i18n/index.js";
|
|
10
11
|
|
|
11
12
|
export const LOG_DIR = ".github/.wizard/logs";
|
|
12
|
-
const KEEP = 20; //
|
|
13
|
+
const KEEP = 20; // number of log files to keep
|
|
13
14
|
const GITIGNORE_BODY = "*\n!.gitignore\n";
|
|
14
15
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
16
|
+
// Keys whose values may hold secrets - none of the current prompts do (domain, path, port, auth 'method'),
|
|
17
|
+
// but this is in place from the start so future additions are not logged in plain text.
|
|
17
18
|
const SECRET_KEY_RE = /(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL)/i;
|
|
18
19
|
const MASK = "***";
|
|
19
20
|
|
|
20
21
|
let state = null; // { targetRoot, name, header, file, rel, clock, startedAt, disabled }
|
|
21
22
|
|
|
22
23
|
export function maskValue(key, value) {
|
|
23
|
-
// SSH_AUTH_METHOD
|
|
24
|
+
// Keys like SSH_AUTH_METHOD hold only a "method", not a secret - not masked even though the name contains KEY.
|
|
24
25
|
if (key === "SSH_AUTH_METHOD") return value;
|
|
25
26
|
return SECRET_KEY_RE.test(key) ? MASK : value;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
// "2026-08-26 12:03:41"
|
|
29
|
+
// "2026-08-26 12:03:41" -> "20260826-120341", so the filename doubles as the sort key.
|
|
29
30
|
export function stampFrom(now = "") {
|
|
30
31
|
const m = String(now).match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
|
|
31
32
|
if (!m) return "unknown";
|
|
32
33
|
return `${m[1]}${m[2]}${m[3]}-${m[4]}${m[5]}${m[6]}`;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
36
|
+
// A seconds-only name would overwrite the previous run's log when runs follow within the same second - milliseconds are appended.
|
|
37
|
+
// Milliseconds are also in time order, so the premise that name order equals run order (rotate) still holds.
|
|
37
38
|
export function logFilename(now, action = "install", ms = 0) {
|
|
38
39
|
return `${stampFrom(now)}-${String(ms).padStart(3, "0")}-${action}.log`;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
//
|
|
42
|
-
// wx
|
|
42
|
+
// If the name already exists (another process opened one in the same millisecond), -2, -3 is appended for a new file.
|
|
43
|
+
// The wx flag decides "create only if absent" atomically, so concurrent runs never overwrite each other.
|
|
43
44
|
function createUnique(dir, base, header) {
|
|
44
45
|
const stem = base.replace(/\.log$/, "");
|
|
45
46
|
for (let n = 1; n < 100; n++) {
|
|
@@ -51,11 +52,11 @@ function createUnique(dir, base, header) {
|
|
|
51
52
|
if (e.code !== "EEXIST") throw e;
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
|
-
throw new Error(
|
|
55
|
+
throw new Error(t("core.logger.error.nameExhausted", { base }));
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
58
|
+
// Keep only the latest KEEP files, deleting the oldest first. Filenames ascend by time, so name sorting is enough.
|
|
59
|
+
// A new file is about to be added, so trim down to KEEP-1.
|
|
59
60
|
function rotate(dir) {
|
|
60
61
|
const logs = readdirSync(dir).filter((f) => f.endsWith(".log")).sort();
|
|
61
62
|
for (const f of logs.slice(0, Math.max(0, logs.length - (KEEP - 1)))) {
|
|
@@ -63,12 +64,12 @@ function rotate(dir) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
67
|
+
// The file is created on the first record - so runs that change nothing (read-only modes like status/doctor,
|
|
68
|
+
// or runs rejected during argument validation) do not leave a log folder and a header-only file in the target repo.
|
|
68
69
|
export function initLogger(targetRoot, opts = {}) {
|
|
69
70
|
const { action = "install", now = "", argv = [], templateVersion = "unknown", clock = () => new Date(),
|
|
70
71
|
ms = new Date().getUTCMilliseconds() } = opts;
|
|
71
|
-
//
|
|
72
|
+
// Times are UTC - reading them as local time looks off by hours, so the header says so.
|
|
72
73
|
const header =
|
|
73
74
|
`=== project-auto-wizard v${templateVersion} | ${action} | ${now} UTC ===\n` +
|
|
74
75
|
`argv : ${["project-auto-wizard", ...argv].join(" ")}\n` +
|
|
@@ -79,12 +80,12 @@ export function initLogger(targetRoot, opts = {}) {
|
|
|
79
80
|
return { get path() { return st.rel; } };
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
//
|
|
83
|
+
// Opens the log file just before the first record. On failure, later records are turned off.
|
|
83
84
|
function open(st) {
|
|
84
85
|
try {
|
|
85
86
|
const dir = join(st.targetRoot, LOG_DIR);
|
|
86
87
|
mkdirSync(dir, { recursive: true });
|
|
87
|
-
//
|
|
88
|
+
// Respect a .gitignore the user placed themselves.
|
|
88
89
|
const gi = join(dir, ".gitignore");
|
|
89
90
|
if (!existsSync(gi)) writeFileSync(gi, GITIGNORE_BODY);
|
|
90
91
|
rotate(dir);
|
|
@@ -92,9 +93,9 @@ function open(st) {
|
|
|
92
93
|
st.file = join(st.targetRoot, st.rel);
|
|
93
94
|
return true;
|
|
94
95
|
} catch (e) {
|
|
95
|
-
//
|
|
96
|
+
// Failing to write the log is no reason to roll back the install - but it is not swallowed silently either.
|
|
96
97
|
st.disabled = true;
|
|
97
|
-
process.stderr.write(`[warn]
|
|
98
|
+
process.stderr.write(`[warn] ${t("core.logger.warn.startFailed", { message: e.message })}\n`);
|
|
98
99
|
return false;
|
|
99
100
|
}
|
|
100
101
|
}
|
|
@@ -103,13 +104,13 @@ export function resetLogger() {
|
|
|
103
104
|
state = null;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
//
|
|
107
|
+
// Log path of this run (repo-relative). Used by the install summary screen to tell the user.
|
|
107
108
|
export function currentLogPath() {
|
|
108
109
|
return state && !state.disabled ? state.rel : "";
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
112
|
+
// Whether old-format (.md) install records remain - .gitignore has no effect on files git already
|
|
113
|
+
// tracks, so if any exist the user must be told to untrack them manually.
|
|
113
114
|
export function hasLegacyMdLogs(targetRoot) {
|
|
114
115
|
try {
|
|
115
116
|
const dir = join(targetRoot, LOG_DIR);
|
|
@@ -117,16 +118,16 @@ export function hasLegacyMdLogs(targetRoot) {
|
|
|
117
118
|
} catch { return false; }
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
//
|
|
121
|
-
const SCOPE_W = 8; //
|
|
121
|
+
// Column widths - fixed so columns line up when skimmed and can be split by column when parsed.
|
|
122
|
+
const SCOPE_W = 8; // fits the longest scope ('baseline') - misaligned columns make skimming worse
|
|
122
123
|
const ACTION_W = 10;
|
|
123
124
|
|
|
124
|
-
//
|
|
125
|
+
// East Asian full-width characters count as width 2 (for aligning the summary block).
|
|
125
126
|
const WIDE_RE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]/;
|
|
126
127
|
const dispWidth = (s) => [...String(s)].reduce((n, c) => n + (WIDE_RE.test(c) ? 2 : 1), 0);
|
|
127
128
|
|
|
128
|
-
//
|
|
129
|
-
//
|
|
129
|
+
// The header's run time and the filename are UTC (utcNow), so line times are UTC too -
|
|
130
|
+
// local time would put the header and lines off by the timezone offset within the same file.
|
|
130
131
|
function hhmmss(date) {
|
|
131
132
|
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
132
133
|
return `${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}.${p(date.getUTCMilliseconds(), 3)}`;
|
|
@@ -139,9 +140,9 @@ function write(level, scope, action, detail = "") {
|
|
|
139
140
|
const line = `${hhmmss(state.clock())} ${level} ${String(scope).padEnd(SCOPE_W)} ${String(action).padEnd(ACTION_W)} ${detail}`.trimEnd();
|
|
140
141
|
appendFileSync(state.file, line + "\n");
|
|
141
142
|
} catch (e) {
|
|
142
|
-
//
|
|
143
|
+
// Report only on the first failure, then quietly turn off - a warning on every line would wreck the install screen.
|
|
143
144
|
state.disabled = true;
|
|
144
|
-
process.stderr.write(`[warn]
|
|
145
|
+
process.stderr.write(`[warn] ${t("core.logger.warn.writeStopped", { message: e.message })}\n`);
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
@@ -149,24 +150,25 @@ export const log = {
|
|
|
149
150
|
info: (scope, action, detail) => write("INFO", scope, action, detail),
|
|
150
151
|
warn: (scope, action, detail) => write("WARN", scope, action, detail),
|
|
151
152
|
fail: (scope, action, detail) => write("FAIL", scope, action, detail),
|
|
152
|
-
// rows: Array<[label, value]>
|
|
153
|
+
// rows: Array<[label, value]> - labels are padded to align.
|
|
153
154
|
summary(rows = []) {
|
|
154
155
|
if (!state || state.disabled || !rows.length) return;
|
|
155
156
|
if (!state.file && !open(state)) return;
|
|
156
|
-
//
|
|
157
|
+
// Hangul takes 2 cells in a terminal - padding by character count looks misaligned.
|
|
157
158
|
const w = Math.max(...rows.map(([k]) => dispWidth(k)));
|
|
158
159
|
const body = rows.map(([k, v]) => `${k}${" ".repeat(w - dispWidth(k))} : ${v}`).join("\n");
|
|
159
160
|
try {
|
|
160
|
-
appendFileSync(state.file, `\n
|
|
161
|
+
appendFileSync(state.file, `\n${t("core.logger.summary.title")}\n${body}\n`);
|
|
161
162
|
} catch {
|
|
162
163
|
state.disabled = true;
|
|
163
164
|
}
|
|
164
165
|
},
|
|
165
166
|
};
|
|
166
167
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
168
|
+
// For removal runs that may delete the log folder (.github/.wizard) itself. Writing before the delete
|
|
169
|
+
// loses the records along with it and raises an ENOENT warning, and writing after would resurrect the folder,
|
|
170
|
+
// leaving a trace even after a full removal. So records are collected and written only if the folder survives
|
|
171
|
+
// (if it is gone, the result is shown on screen instead).
|
|
170
172
|
export function logRemovals(targetRoot, entries = []) {
|
|
171
173
|
if (!existsSync(dirname(join(targetRoot, LOG_DIR)))) return;
|
|
172
174
|
for (const [scope, action, detail] of entries) write("INFO", scope, action, detail);
|